diff --git a/bun.lock b/bun.lock index 3216f775..6c195032 100644 --- a/bun.lock +++ b/bun.lock @@ -203,6 +203,22 @@ "typescript": "^5.7.2", }, }, + "github-repo-reports": { + "name": "github-repo-reports", + "version": "1.0.0", + "dependencies": { + "@decocms/runtime": "1.2.5", + "@octokit/rest": "^22.0.1", + "yaml": "^2.8.2", + "zod": "^4.0.0", + }, + "devDependencies": { + "@decocms/mcps-shared": "1.0.0", + "@modelcontextprotocol/sdk": "^1.25.1", + "deco-cli": "^0.28.0", + "typescript": "^5.7.2", + }, + }, "google-apps-script": { "name": "google-apps-script", "version": "1.0.0", @@ -2139,6 +2155,8 @@ "github": ["github@workspace:github"], + "github-repo-reports": ["github-repo-reports@workspace:github-repo-reports"], + "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -2823,6 +2841,8 @@ "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], @@ -3227,6 +3247,14 @@ "github/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "github-repo-reports/@decocms/runtime": ["@decocms/runtime@1.2.5", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.1.1", "@modelcontextprotocol/sdk": "1.25.2", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-0s02lfj/O7nTAc7FTmFsA+lZpUDnapjQHnRYrQXItLKrbJvjSnfoq5V8HA1Npv5HelBvsVk7QQHaW8pSN/l37w=="], + + "github-repo-reports/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.3", "", { "dependencies": { "@hono/node-server": "^1.19.9", "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.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "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.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ=="], + + "github-repo-reports/@octokit/rest": ["@octokit/rest@22.0.1", "", { "dependencies": { "@octokit/core": "^7.0.6", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^17.0.0" } }, "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw=="], + + "github-repo-reports/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "google-apps-script/@decocms/runtime": ["@decocms/runtime@1.2.5", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.1.1", "@modelcontextprotocol/sdk": "1.25.2", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-0s02lfj/O7nTAc7FTmFsA+lZpUDnapjQHnRYrQXItLKrbJvjSnfoq5V8HA1Npv5HelBvsVk7QQHaW8pSN/l37w=="], "google-apps-script/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], @@ -3627,6 +3655,18 @@ "gemini-pro-vision/@modelcontextprotocol/sdk/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "github-repo-reports/@decocms/runtime/@decocms/bindings": ["@decocms/bindings@1.2.0", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.25.3", "@tanstack/react-router": "1.139.7", "react": "^19.2.0", "zod": "^4.0.0", "zod-from-json-schema": "^0.5.2" } }, "sha512-+4/VOOVERB8UixGKmN0VkLazxeMAahbG0A9xOYTPL+MJIAM30htrLG2aHI2Dm5ASgccAD4bW5RuLqv2PDFZZPA=="], + + "github-repo-reports/@decocms/runtime/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "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.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "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.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="], + + "github-repo-reports/@octokit/rest/@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="], + + "github-repo-reports/@octokit/rest/@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="], + + "github-repo-reports/@octokit/rest/@octokit/plugin-request-log": ["@octokit/plugin-request-log@6.0.0", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q=="], + + "github-repo-reports/@octokit/rest/@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@17.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw=="], + "github/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "google-apps-script/@decocms/runtime/@decocms/bindings": ["@decocms/bindings@1.2.0", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.25.3", "@tanstack/react-router": "1.139.7", "react": "^19.2.0", "zod": "^4.0.0", "zod-from-json-schema": "^0.5.2" } }, "sha512-+4/VOOVERB8UixGKmN0VkLazxeMAahbG0A9xOYTPL+MJIAM30htrLG2aHI2Dm5ASgccAD4bW5RuLqv2PDFZZPA=="], @@ -4027,6 +4067,24 @@ "gemini-pro-vision/@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "github-repo-reports/@decocms/runtime/@decocms/bindings/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.3", "", { "dependencies": { "@hono/node-server": "^1.19.9", "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.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "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.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ=="], + + "github-repo-reports/@octokit/rest/@octokit/core/@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="], + + "github-repo-reports/@octokit/rest/@octokit/core/@octokit/graphql": ["@octokit/graphql@9.0.3", "", { "dependencies": { "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA=="], + + "github-repo-reports/@octokit/rest/@octokit/core/@octokit/request": ["@octokit/request@10.0.7", "", { "dependencies": { "@octokit/endpoint": "^11.0.2", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "fast-content-type-parse": "^3.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA=="], + + "github-repo-reports/@octokit/rest/@octokit/core/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + + "github-repo-reports/@octokit/rest/@octokit/core/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + + "github-repo-reports/@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], + + "github-repo-reports/@octokit/rest/@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + + "github-repo-reports/@octokit/rest/@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + "google-apps-script/@decocms/runtime/@decocms/bindings/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.3", "", { "dependencies": { "@hono/node-server": "^1.19.9", "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.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "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.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ=="], "google-big-query/@decocms/runtime/@decocms/bindings/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.3", "", { "dependencies": { "@hono/node-server": "^1.19.9", "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.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "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.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ=="], @@ -4385,6 +4443,16 @@ "gemini-pro-vision/@decocms/runtime/@mastra/core/ai-v5/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.10", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-T1gZ76gEIwffep6MWI0QNy9jgoybUHE7TRaHB5k54K8mF91ciGFlbtCGxDYhMH3nCRergKwYFIDeFF0hJSIQHQ=="], + "github-repo-reports/@octokit/rest/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.2", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ=="], + + "github-repo-reports/@octokit/rest/@octokit/core/@octokit/request/fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], + + "github-repo-reports/@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + + "github-repo-reports/@octokit/rest/@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + + "github-repo-reports/@octokit/rest/@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + "grain/@decocms/runtime/@deco/mcp/@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "grain/@decocms/runtime/@deco/mcp/@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], diff --git a/deploy.json b/deploy.json index 125437b4..366c5083 100644 --- a/deploy.json +++ b/deploy.json @@ -196,5 +196,11 @@ "entrypoint": "./dist/server/main.js", "platformName": "kubernetes-bun", "watch": ["nanobanana/**", "shared/**"] + }, + "github-repo-reports": { + "site": "github-repo-reports", + "entrypoint": "./dist/server/main.js", + "platformName": "kubernetes-bun", + "watch": ["github-repo-reports/**", "shared/**"] } } diff --git a/docs/REPORTS_BINDING.md b/docs/REPORTS_BINDING.md new file mode 100644 index 00000000..5fe52522 --- /dev/null +++ b/docs/REPORTS_BINDING.md @@ -0,0 +1,225 @@ +# Reports Binding + +The **Reports Binding** is a standardized interface that enables MCP servers to expose structured reports (performance audits, security scans, accessibility checks, CI summaries, etc.) as first-class resources. Each report has a **status** (health outcome), a **lifecycle status** (workflow state), and **sections** (rich content blocks). + +A connection is detected as reports-compatible when it exposes all **required** tools listed below. + +This repository contains one implementation: + +| MCP | Backend | Auth | +|---|---|---| +| `github-repo-reports/` | Markdown files with YAML frontmatter in a GitHub repository | GitHub App OAuth (PKCE) | + +--- + +## Binding Tools + +### Required + +| Tool | Purpose | +|---|---| +| `REPORTS_LIST` | List available reports with optional filters | +| `REPORTS_GET` | Get a single report with full content | + +### Optional + +| Tool | Purpose | +|---|---| +| `REPORTS_UPDATE_STATUS` | Update the lifecycle status of a report (`unread` / `read` / `dismissed`) | + +Optional tools may be omitted. Consumers will skip the corresponding functionality when they are absent. + +--- + +## Schemas + +All tool inputs and outputs must be returned as **structured content** (JSON). The MCP SDK `structuredContent` field is used alongside the standard `content` text array. + +### Shared Types + +#### ReportStatus + +Overall health/outcome of the report: + +``` +"passing" | "warning" | "failing" | "info" +``` + +#### ReportLifecycleStatus + +Workflow state: + +``` +"unread" | "read" | "dismissed" +``` + +| Value | Meaning | +|---|---| +| `unread` | New report, not yet viewed. | +| `read` | Report has been viewed. | +| `dismissed` | Report has been archived / marked as done. | + +#### MetricItem + +```json +{ + "label": "string — metric label (e.g. 'LCP', 'Performance')", + "value": "number | string — current value", + "unit?": "string — unit of measurement (e.g. 's', 'ms', 'score')", + "previousValue?": "number | string — previous value for delta comparison", + "status?": "ReportStatus — status of this individual metric" +} +``` + +#### ReportSection (discriminated union on `type`) + +**Markdown section** +```json +{ + "type": "markdown", + "content": "string — markdown content (GFM supported)" +} +``` + +**Metrics section** +```json +{ + "type": "metrics", + "title?": "string — section title", + "items": "MetricItem[] — array of metric items" +} +``` + +**Table section** +```json +{ + "type": "table", + "title?": "string — section title", + "columns": "string[] — column headers", + "rows": "(string | number | null)[][] — table rows" +} +``` + +#### ReportSummary + +Returned by `REPORTS_LIST`. Contains metadata only (no sections). + +```json +{ + "id": "string — unique report identifier", + "title": "string — report title", + "category": "string — e.g. 'performance', 'security', 'accessibility'", + "status": "ReportStatus — overall health outcome", + "summary": "string — one-line summary of findings", + "updatedAt": "string — ISO 8601 timestamp", + "source?": "string — agent or service that generated the report", + "tags?": "string[] — free-form tags for filtering", + "lifecycleStatus?": "ReportLifecycleStatus — workflow state (default: 'unread')" +} +``` + +#### Report (full) + +Returned by `REPORTS_GET`. Extends `ReportSummary` with content. + +```json +{ + "...ReportSummary fields", + "sections": "ReportSection[] — ordered content sections" +} +``` + +--- + +## Tool Specifications + +### `REPORTS_LIST` + +Lists available reports with optional filtering. + +- **Input**: `{ category?: string, status?: ReportStatus }` +- **Output**: `{ reports: ReportSummary[] }` + +Notes: +- Return all reports when no filters are provided. +- Reports with `lifecycleStatus: "dismissed"` are considered archived; everything else is active. Reports with `lifecycleStatus: "unread"` (or no `lifecycleStatus`) are new. +- Order reports by `updatedAt` descending (most recent first) unless the server has a more meaningful ordering. + +### `REPORTS_GET` + +Retrieves a single report by ID with full sections. + +- **Input**: `{ id: string }` +- **Output**: The full `Report` object (see schema above). + +Notes: +- Return an MCP error (`isError: true`) if the report ID is not found. +- Sections are rendered in array order — put the most important information first. + +### `REPORTS_UPDATE_STATUS` (optional) + +Updates the lifecycle status of a report. + +- **Input**: `{ reportId: string, lifecycleStatus: ReportLifecycleStatus }` +- **Output**: `{ success: boolean, message?: string }` + +Notes: +- Consumers call this automatically when a report is opened (sets `"read"`). +- `"dismissed"` archives the report. Restoring from dismissed sets `"read"`. +- If not implemented, lifecycle tracking is unavailable but the binding remains usable as a read-only viewer. + +--- + +## Binding Detection + +A connection is considered reports-compatible when it exposes at minimum: +- `REPORTS_LIST` +- `REPORTS_GET` + +Detection checks tool name presence (exact string match). No schema validation is performed at detection time. + +--- + +## Categories + +Categories are free-form strings. Common conventions: + +| Category | Use case | +|---|---| +| `performance` | Web vitals, bundle size, load times | +| `security` | Vulnerability scans, dependency audits | +| `accessibility` | WCAG compliance, axe-core results | +| `seo` | Meta tags, structured data, crawlability | +| `quality` | Code quality, test coverage, lint results | +| `uptime` | Health checks, availability monitoring | +| `compliance` | License audits, policy checks | + +--- + +## Implementation Details: GitHub Repo Reports (`github-repo-reports/`) + +- **Storage**: Markdown files with YAML frontmatter in a configurable GitHub repository directory +- **Auth**: GitHub App OAuth (PKCE) — user selects which repositories to grant access during installation +- **Configuration** (`StateSchema`): `REPO` (owner/repo), `PATH` (default: `reports`), `BRANCH` (default: `reports`) +- **Report IDs**: Relative file path without `.md` extension (e.g., `security/audit` for `reports/security/audit.md`) +- **Tags from directory nesting**: `reports/security/audit.md` automatically receives tag `["security"]`, merged with frontmatter tags +- **Lifecycle persistence**: Stored in `.reports-status.json` committed to the same branch (zero server-side state) +- **Data fetching**: Git Trees API for listing, Git Blobs API for parallel content fetch, Contents API for single-file reads and status file writes +- **Parser**: YAML frontmatter extracted for metadata + structured sections; markdown body appended as a final `markdown` section +- **Dependencies**: `@octokit/rest`, `yaml`, `@decocms/runtime` + +### Report File Format + +See [`github-repo-reports/REPORT_FORMAT.md`](../github-repo-reports/REPORT_FORMAT.md) for the full file format specification and authoring guide. + +--- + +## Implementation Checklist + +1. **Register both required tools** (`REPORTS_LIST`, `REPORTS_GET`) in your MCP server. +2. **Return structured content** — set both `content` (text array) and `structuredContent` (typed JSON) on every tool response. +3. **Use consistent report IDs** — consumers use `id` to navigate between list and detail views. +4. **Provide meaningful sections** — use `markdown` for narrative, `metrics` for KPIs with deltas, and `table` for tabular data. Order them from most to least important. +5. **Support filtering** — handle `category` and `status` filters in `REPORTS_LIST` (return all when omitted). +6. **Set `lifecycleStatus`** — default to `"unread"` for new reports. The field is optional (omitted is treated as `"unread"`). +7. **(Optional) Implement `REPORTS_UPDATE_STATUS`** for full lifecycle workflow support (read tracking, dismiss/restore). diff --git a/github-repo-reports/.gitignore b/github-repo-reports/.gitignore new file mode 100644 index 00000000..9a0afafb --- /dev/null +++ b/github-repo-reports/.gitignore @@ -0,0 +1,33 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Environment +.env +.env.local + +# OS files +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Data +data/ +*.db +*.sqlite + +# Template files +# Note: Keep app.json.example, ignore app.json if it's just a copy +# app.json + diff --git a/github-repo-reports/README.md b/github-repo-reports/README.md new file mode 100644 index 00000000..9de7a330 --- /dev/null +++ b/github-repo-reports/README.md @@ -0,0 +1,13 @@ +# github-repo-reports + +GitHub-backed reports MCP server implementing the Reports Binding. Stores and reads reports as Markdown files with YAML frontmatter from a configurable GitHub repository. + +## Getting Started + +1. Configure your MCP in `server/types/env.ts` +2. Implement tools in `server/tools/` +3. Rename `app.json.example` to `app.json` and customize +4. Add to `deploy.json` for deployment +5. Test with `bun run dev` + +See [template-minimal/README.md](../template-minimal/README.md) for detailed instructions. diff --git a/github-repo-reports/REPORT_FORMAT.md b/github-repo-reports/REPORT_FORMAT.md new file mode 100644 index 00000000..1fb6f5d7 --- /dev/null +++ b/github-repo-reports/REPORT_FORMAT.md @@ -0,0 +1,338 @@ +# Report Format + +You are writing a report as a Markdown file with YAML frontmatter. Follow these instructions exactly. + +--- + +## File format + +Every report is a `.md` file. Metadata goes in YAML frontmatter delimited by `---`. Any markdown content below the frontmatter becomes the report body. + +```markdown +--- +title: "Report Title" +category: performance +status: warning +summary: "One-line summary of findings" +source: your-agent-name +tags: [extra-tag-1, extra-tag-2] +updatedAt: "2025-06-15T10:00:00Z" +sections: + - type: metrics + title: "Section Title" + items: + - label: "Metric Name" + value: 42 + unit: "ms" + status: passing +--- + +## Markdown body + +Any content below the closing `---` is rendered as a markdown section +after the structured sections declared in frontmatter. +``` + +--- + +## Frontmatter fields + +| Field | Required | Type | Description | +|---|---|---|---| +| `title` | **Yes** | `string` | Human-readable report title. | +| `category` | **Yes** | `string` | Category for filtering (e.g., `performance`, `security`, `quality`). See category list below. | +| `status` | **Yes** | `"passing" \| "warning" \| "failing" \| "info"` | Overall health outcome. See status reference below. | +| `summary` | **Yes** | `string` | One-line summary of findings. Keep it concise -- this is shown in list views. | +| `updatedAt` | **Yes** | `string` | ISO 8601 timestamp of when the report was generated (`YYYY-MM-DDTHH:mm:ssZ`). | +| `source` | Optional | `string` | Name of the agent or service that generated this report (e.g., `lighthouse`, `security-scanner`). | +| `tags` | Optional | `string[]` | Free-form tags for filtering (e.g., `[homepage, mobile, ci]`). | +| `sections` | Optional | `ReportSection[]` | Structured content sections (metrics, tables). See section types below. | + +Always provide `title`, `category`, `status`, `summary`, and `updatedAt`. Do not leave them empty or omit them. + +--- + +## File placement and tagging + +Place report files inside a reports directory. Subdirectories automatically become tags on the report. + +``` +reports/ + daily-check.md # no directory tags + security/ + audit.md # tagged: ["security"] + dependency-scan.md # tagged: ["security"] + performance/ + mobile/ + lighthouse.md # tagged: ["performance", "mobile"] +``` + +Directory-derived tags are merged with any `tags` declared in frontmatter, deduplicated. + +Choose a filename that is a short, descriptive, kebab-case slug (e.g., `dependency-scan.md`, `homepage-vitals.md`). + +--- + +## Section types + +Sections appear in the order listed in frontmatter. The markdown body (if present) is appended as a final section after all frontmatter sections. + +Put the most important information first. + +### Markdown section + +Free-form text rendered as GitHub Flavored Markdown. + +```yaml +sections: + - type: markdown + content: | + ## Analysis + + The homepage load time has improved by **15%** since last week. + See the metrics below for details. +``` + +### Metrics section + +Key performance indicators with optional deltas and per-metric status. + +```yaml +sections: + - type: metrics + title: "Core Web Vitals" + items: + - label: LCP + value: 2.5 + unit: s + previousValue: 3.1 + status: passing + - label: FID + value: 300 + unit: ms + previousValue: 150 + status: failing + - label: CLS + value: 0.05 + status: passing +``` + +Each metric item: + +| Field | Required | Type | Description | +|---|---|---|---| +| `label` | **Yes** | `string` | Metric name (e.g., "LCP", "Coverage"). | +| `value` | **Yes** | `number \| string` | Current value. | +| `unit` | Optional | `string` | Unit of measurement (e.g., "s", "ms", "%", "score"). | +| `previousValue` | Optional | `number \| string` | Previous value for delta comparison. | +| `status` | Optional | `"passing" \| "warning" \| "failing" \| "info"` | Status of this individual metric. | + +### Table section + +Tabular data with column headers. + +```yaml +sections: + - type: table + title: "Dependency Vulnerabilities" + columns: [Package, Severity, Version, Fixed In] + rows: + - [lodash, High, 4.17.20, 4.17.21] + - [express, Medium, 4.17.1, 4.18.0] + - [axios, Low, 0.21.1, 0.21.2] +``` + +--- + +## Status reference + +| Status | When to use | +|---|---| +| `passing` | Everything is within acceptable thresholds. | +| `warning` | Some metrics are degraded or approaching thresholds. | +| `failing` | Critical issues that need immediate attention. | +| `info` | Informational report with no pass/fail judgment. | + +## Category conventions + +Use these common categories or define your own: + +| Category | Use case | +|---|---| +| `performance` | Web vitals, bundle size, load times. | +| `security` | Vulnerability scans, dependency audits. | +| `accessibility` | WCAG compliance, axe-core results. | +| `seo` | Meta tags, structured data, crawlability. | +| `quality` | Code quality, test coverage, lint results. | +| `uptime` | Health checks, availability monitoring. | +| `compliance` | License audits, policy checks. | + +--- + +## Complete examples + +### Simple report (metadata + markdown body only) + +```markdown +--- +title: "Daily Health Check" +category: uptime +status: passing +summary: "All 12 endpoints responding within SLA" +source: health-monitor +updatedAt: "2025-06-15T08:00:00Z" +--- + +## Details + +All endpoints checked at 08:00 UTC. Average response time: 145ms. +No errors detected in the last 24 hours. +``` + +### Report with metrics and tables + +```markdown +--- +title: "Homepage Performance Audit" +category: performance +status: warning +summary: "LCP exceeds 2.5s threshold on mobile" +source: lighthouse +tags: [homepage, mobile] +updatedAt: "2025-06-15T10:30:00Z" +sections: + - type: metrics + title: "Core Web Vitals" + items: + - label: LCP + value: 3.2 + unit: s + previousValue: 2.8 + status: failing + - label: FID + value: 45 + unit: ms + previousValue: 50 + status: passing + - label: CLS + value: 0.08 + previousValue: 0.12 + status: passing + - type: metrics + title: "Performance Score" + items: + - label: Overall + value: 72 + unit: score + previousValue: 78 + status: warning + - type: table + title: "Largest Resources" + columns: [Resource, Type, Size, Load Time] + rows: + - [hero-image.webp, Image, 450KB, 1.8s] + - [main.js, Script, 320KB, 1.2s] + - [vendor.js, Script, 280KB, 0.9s] + - [fonts.woff2, Font, 85KB, 0.3s] +--- + +## Recommendations + +1. **Optimize hero image** — compress or use a smaller viewport-specific version. +2. **Code-split main.js** — defer non-critical modules to reduce initial parse time. +3. **Preload fonts** — add `` to eliminate the font swap delay. +``` + +### Security scan report + +```markdown +--- +title: "Dependency Vulnerability Scan" +category: security +status: failing +summary: "3 high-severity vulnerabilities found" +source: security-auditor +tags: [dependencies, npm] +updatedAt: "2025-06-15T06:00:00Z" +sections: + - type: metrics + title: "Vulnerability Summary" + items: + - label: Critical + value: 0 + status: passing + - label: High + value: 3 + status: failing + - label: Medium + value: 7 + status: warning + - label: Low + value: 12 + status: info + - type: table + title: "High Severity Issues" + columns: [Package, CVE, Severity, Installed, Patched] + rows: + - [lodash, CVE-2024-1234, High, 4.17.20, 4.17.21] + - [express, CVE-2024-5678, High, 4.17.1, 4.18.2] + - [jsonwebtoken, CVE-2024-9012, High, 8.5.1, 9.0.0] +--- + +## Action Required + +Run `npm audit fix` to apply automatic patches. The `jsonwebtoken` upgrade +is a major version bump — review the migration guide before upgrading. +``` + +--- + +## Git workflow + +Reports are stored on a dedicated git branch (typically `reports`). + +### Committing a report + +```bash +# Switch to the reports branch +git checkout reports + +# Create the report file in the appropriate directory +# (use subdirectories for tags) +mkdir -p reports/security +# ... write the file ... + +# Commit and push +git add reports/ +git commit -m "report(security): add dependency vulnerability scan" +git push origin reports +``` + +### Creating the branch for the first time + +```bash +git checkout --orphan reports +git rm -rf . +mkdir reports +# ... create your first report file ... +git add reports/ +git commit -m "report: initialize reports branch" +git push -u origin reports +``` + +### CI pipeline pattern + +```bash +git config user.name "report-bot" +git config user.email "reports@example.com" + +git fetch origin reports +git checkout reports + +mkdir -p reports/performance +# ... write the report file ... + +git add reports/ +git commit -m "report(performance): lighthouse CI results" +git push origin reports +``` diff --git a/github-repo-reports/app.json b/github-repo-reports/app.json new file mode 100644 index 00000000..b5cf09ed --- /dev/null +++ b/github-repo-reports/app.json @@ -0,0 +1,32 @@ +{ + "scopeName": "deco", + "name": "github-repo-reports", + "friendlyName": "GitHub Repo Reports", + "connection": { + "type": "HTTP", + "url": "https://sites-github-repo-reports.decocache.com/mcp" + }, + "description": "GitHub-backed reports MCP server implementing the Reports Binding. Stores and reads reports as Markdown files with YAML frontmatter from a configurable GitHub repository.", + "icon": "https://assets.decocache.com/mcp/{uuid}/icon.png", + "unlisted": false, + "auth": { + "type": "token", + "header": "Authorization", + "prefix": "Bearer" + }, + "metadata": { + "categories": [ + "Productivity", + "Developer Tools" + ], + "official": false, + "tags": [ + "github", + "reports", + "markdown", + "mcp" + ], + "short_description": "Read and manage reports stored as Markdown files in a GitHub repository.", + "mesh_description": "Implements the **Reports Binding** to display an inbox-style UI of reports sourced from a GitHub repository. Reports are stored as **Markdown files with YAML frontmatter** under a configurable directory. **Key Features** — Reads reports directly from GitHub (no server-side persistence). Supports **directory nesting as tags** (e.g., reports/security/audit.md gets tag \"security\"). Full lifecycle management (unread/read/dismissed) persisted in the repo. Supports structured sections: markdown, metrics with KPIs, and tables. **Authentication** — Uses GitHub App OAuth so users can grant access to specific repositories. **Configuration** — Set the target repository, reports directory path, and branch via the Mesh UI." + } +} diff --git a/github-repo-reports/app.json.example b/github-repo-reports/app.json.example new file mode 100644 index 00000000..0b846366 --- /dev/null +++ b/github-repo-reports/app.json.example @@ -0,0 +1,25 @@ +{ + "scopeName": "deco", + "name": "my-mcp", + "friendlyName": "My MCP", + "connection": { + "type": "HTTP", + "url": "https://sites-my-mcp.decocache.com/mcp" + }, + "description": "Short description of what this MCP does (1-2 sentences)", + "icon": "https://assets.decocache.com/mcp/{uuid}/icon.png", + "unlisted": false, + "auth": { + "type": "token", + "header": "Authorization", + "prefix": "Bearer" + }, + "metadata": { + "categories": ["Productivity"], + "official": false, + "tags": ["example", "template", "mcp"], + "short_description": "Short description of what this MCP does", + "mesh_description": "Detailed description of your MCP (max 1500 characters). Explain what it does, key features, use cases, and authentication method. Use **bold** for feature names. Example: **Key Features** - Feature 1 description. **Use Cases** - Use case description. **Authentication** - How to authenticate. Perfect for developers who need [your use case]. Provides [your benefit]." + } +} + diff --git a/github-repo-reports/package.json b/github-repo-reports/package.json new file mode 100644 index 00000000..89075ff7 --- /dev/null +++ b/github-repo-reports/package.json @@ -0,0 +1,28 @@ +{ + "name": "github-repo-reports", + "version": "1.0.0", + "description": "GitHub-backed reports MCP server implementing the Reports Binding. Stores and reads reports as Markdown files with YAML frontmatter from a configurable GitHub repository.", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run --hot server/main.ts", + "check": "tsc --noEmit", + "build:server": "NODE_ENV=production bun build server/main.ts --target=bun --outfile=dist/server/main.js", + "build": "bun run build:server" + }, + "dependencies": { + "@decocms/runtime": "1.2.5", + "@octokit/rest": "^22.0.1", + "yaml": "^2.8.2", + "zod": "^4.0.0" + }, + "devDependencies": { + "@decocms/mcps-shared": "1.0.0", + "@modelcontextprotocol/sdk": "^1.25.1", + "deco-cli": "^0.28.0", + "typescript": "^5.7.2" + }, + "engines": { + "node": ">=22.0.0" + } +} diff --git a/github-repo-reports/server/lib/config.ts b/github-repo-reports/server/lib/config.ts new file mode 100644 index 00000000..5250b8c3 --- /dev/null +++ b/github-repo-reports/server/lib/config.ts @@ -0,0 +1,67 @@ +/** + * Configuration helpers + * + * Extracts and validates GitHub repo configuration from the MCP environment. + */ + +import type { Env } from "../types/env.ts"; +import { ReportsGitHubClient } from "./github-client.ts"; + +export interface RepoConfig { + owner: string; + repo: string; + branch: string; + path: string; +} + +/** + * Extract validated repo configuration from the environment state. + * Throws if required fields are missing or malformed. + */ +export function getRepoConfig(env: Env): RepoConfig { + const state = env.MESH_REQUEST_CONTEXT?.state; + + const repoStr = state?.REPO; + if (!repoStr || typeof repoStr !== "string") { + throw new Error( + 'REPO configuration is required. Set it to "owner/repo" format.', + ); + } + + const parts = repoStr.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error( + `Invalid REPO format: "${repoStr}". Expected "owner/repo" (e.g., "acme/my-reports").`, + ); + } + + return { + owner: parts[0], + repo: parts[1], + branch: (state?.BRANCH as string) || "reports", + path: (state?.PATH as string) || "reports", + }; +} + +/** Status file path within the reports directory. */ +export const STATUS_FILE_NAME = ".reports-status.json"; + +/** + * Build the full path to the lifecycle status file. + */ +export function getStatusFilePath(reportsPath: string): string { + return `${reportsPath}/${STATUS_FILE_NAME}`; +} + +/** + * Create a ReportsGitHubClient from the environment's OAuth token. + */ +export function getGitHubClient(env: Env): ReportsGitHubClient { + const token = env.MESH_REQUEST_CONTEXT?.authorization; + if (!token) { + throw new Error( + "GitHub authentication required. Please connect your GitHub account.", + ); + } + return ReportsGitHubClient.for(token); +} diff --git a/github-repo-reports/server/lib/github-client.ts b/github-repo-reports/server/lib/github-client.ts new file mode 100644 index 00000000..3f34732a --- /dev/null +++ b/github-repo-reports/server/lib/github-client.ts @@ -0,0 +1,269 @@ +/** + * GitHub API Client for Reports + * + * Provides methods to interact with GitHub's Git Data and Contents APIs + * for reading report files and managing lifecycle status persistence. + * + * Uses the Octokit REST client with an OAuth access token. + */ + +import { Octokit } from "@octokit/rest"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** A single entry from the Git Tree API (blob or tree). */ +export interface TreeEntry { + path: string; + mode: string; + type: "blob" | "tree"; + sha: string; + size?: number; +} + +/** Decoded file content returned by the Contents API. */ +export interface FileContent { + path: string; + sha: string; + content: string; + encoding: string; +} + +/** Parameters shared by most repository-scoped methods. */ +interface RepoParams { + owner: string; + repo: string; + branch: string; +} + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + +export class ReportsGitHubClient { + private octokit: Octokit; + + constructor(accessToken: string) { + this.octokit = new Octokit({ auth: accessToken }); + } + + /** Factory shorthand. */ + static for(accessToken: string): ReportsGitHubClient { + return new ReportsGitHubClient(accessToken); + } + + // ------------------------------------------------------------------------- + // Git Tree API — list all files recursively + // ------------------------------------------------------------------------- + + /** + * Fetch the full recursive tree for a given branch and filter entries + * whose paths start with `prefix` and end with `.md`. + * + * Returns the raw tree entries (path relative to repo root). + */ + async listMarkdownFiles( + params: RepoParams, + prefix: string, + ): Promise { + const { owner, repo, branch } = params; + + // Resolve branch to its tree SHA + const refResponse = await this.octokit.git.getRef({ + owner, + repo, + ref: `heads/${branch}`, + }); + const commitSha = refResponse.data.object.sha; + + const commitResponse = await this.octokit.git.getCommit({ + owner, + repo, + commit_sha: commitSha, + }); + const treeSha = commitResponse.data.tree.sha; + + // Fetch the full tree recursively + const treeResponse = await this.octokit.git.getTree({ + owner, + repo, + tree_sha: treeSha, + recursive: "1", + }); + + const normalizedPrefix = prefix.endsWith("/") ? prefix : `${prefix}/`; + + return (treeResponse.data.tree as TreeEntry[]).filter( + (entry) => + entry.type === "blob" && + entry.path.startsWith(normalizedPrefix) && + entry.path.endsWith(".md"), + ); + } + + // ------------------------------------------------------------------------- + // Git Blob API — fetch file content by SHA + // ------------------------------------------------------------------------- + + /** + * Fetch a blob's content by its SHA and return it decoded as UTF-8 text. + */ + async getBlobContent( + owner: string, + repo: string, + sha: string, + ): Promise { + const response = await this.octokit.git.getBlob({ + owner, + repo, + file_sha: sha, + }); + + if (response.data.encoding === "base64") { + return Buffer.from(response.data.content, "base64").toString("utf-8"); + } + + return response.data.content; + } + + // ------------------------------------------------------------------------- + // Contents API — single file read/write + // ------------------------------------------------------------------------- + + /** + * Get a file's content via the Contents API. + * Returns the decoded UTF-8 content and the file's SHA (needed for updates). + * + * Returns `null` if the file does not exist (404). + */ + async getFileContent( + params: RepoParams, + path: string, + ): Promise<{ content: string; sha: string } | null> { + try { + const response = await this.octokit.repos.getContent({ + owner: params.owner, + repo: params.repo, + path, + ref: params.branch, + }); + + const data = response.data; + + // The Contents API returns an array for directories + if (Array.isArray(data)) { + return null; + } + + if (data.type !== "file" || !("content" in data)) { + return null; + } + + const content = Buffer.from(data.content as string, "base64").toString( + "utf-8", + ); + + return { content, sha: data.sha }; + } catch (error: unknown) { + if (isOctokitError(error) && error.status === 404) { + return null; + } + throw error; + } + } + + /** + * Create or update a file via the Contents API. + * + * If `fileSha` is provided, the file is updated (required by GitHub to + * prevent accidental overwrites). If omitted, the file is created. + */ + async putFileContent( + params: RepoParams, + path: string, + content: string, + message: string, + fileSha?: string, + ): Promise { + const encodedContent = Buffer.from(content, "utf-8").toString("base64"); + + await this.octokit.repos.createOrUpdateFileContents({ + owner: params.owner, + repo: params.repo, + path, + message, + content: encodedContent, + branch: params.branch, + ...(fileSha ? { sha: fileSha } : {}), + }); + } +} + +// --------------------------------------------------------------------------- +// OAuth helper +// --------------------------------------------------------------------------- + +/** + * Exchange a GitHub OAuth authorization code for an access token. + */ +export async function exchangeCodeForToken( + code: string, + clientId: string, + clientSecret: string, +): Promise<{ access_token: string; token_type: string }> { + const response = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: clientId, + client_secret: clientSecret, + code, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`GitHub OAuth failed: ${response.status} - ${errorText}`); + } + + const data = (await response.json()) as { + access_token: string; + token_type: string; + scope?: string; + error?: string; + error_description?: string; + }; + + if (data.error) { + throw new Error( + `GitHub OAuth error: ${data.error_description || data.error}`, + ); + } + + return { + access_token: data.access_token, + token_type: data.token_type || "Bearer", + }; +} + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +interface OctokitError { + status: number; + message: string; +} + +function isOctokitError(error: unknown): error is OctokitError { + return ( + typeof error === "object" && + error !== null && + "status" in error && + typeof (error as OctokitError).status === "number" + ); +} diff --git a/github-repo-reports/server/lib/report-parser.ts b/github-repo-reports/server/lib/report-parser.ts new file mode 100644 index 00000000..8f1949d8 --- /dev/null +++ b/github-repo-reports/server/lib/report-parser.ts @@ -0,0 +1,342 @@ +/** + * Report Parser + * + * Parses Markdown files with YAML frontmatter into the Report schema + * defined by the Reports Binding. + * + * File format: + * ``` + * --- + * title: "Report Title" + * category: performance + * status: warning + * summary: "One-line summary" + * source: my-agent + * tags: [extra-tag] + * updatedAt: "2025-01-15T10:00:00Z" + * sections: + * - type: metrics + * title: "Core Vitals" + * items: + * - label: LCP + * value: 2.5 + * unit: s + * status: passing + * --- + * + * ## Markdown body becomes a section + * ``` + */ + +import { parse as parseYaml } from "yaml"; + +// --------------------------------------------------------------------------- +// Report Binding Types +// --------------------------------------------------------------------------- + +export type ReportStatus = "passing" | "warning" | "failing" | "info"; +export type ReportLifecycleStatus = "unread" | "read" | "dismissed"; + +export interface MetricItem { + label: string; + value: number | string; + unit?: string; + previousValue?: number | string; + status?: ReportStatus; +} + +export interface MarkdownSection { + type: "markdown"; + content: string; +} + +export interface MetricsSection { + type: "metrics"; + title?: string; + items: MetricItem[]; +} + +export interface TableSection { + type: "table"; + title?: string; + columns: string[]; + rows: (string | number | null)[][]; +} + +export type ReportSection = MarkdownSection | MetricsSection | TableSection; + +export interface ReportSummary { + id: string; + title: string; + category: string; + status: ReportStatus; + summary: string; + updatedAt: string; + source?: string; + tags?: string[]; + lifecycleStatus?: ReportLifecycleStatus; +} + +export interface Report extends ReportSummary { + sections: ReportSection[]; +} + +// --------------------------------------------------------------------------- +// Lifecycle status map (from .reports-status.json) +// --------------------------------------------------------------------------- + +export type LifecycleStatusMap = Record; + +// --------------------------------------------------------------------------- +// Frontmatter shape (loosely typed for parsing) +// --------------------------------------------------------------------------- + +interface ReportFrontmatter { + title?: string; + category?: string; + status?: string; + summary?: string; + source?: string; + tags?: string[]; + updatedAt?: string; + sections?: ReportSection[]; +} + +// --------------------------------------------------------------------------- +// Parsing helpers +// --------------------------------------------------------------------------- + +const VALID_STATUSES = new Set([ + "passing", + "warning", + "failing", + "info", +]); + +/** + * Parse a Markdown string with YAML frontmatter. + * Returns the parsed frontmatter object and the markdown body. + */ +function parseFrontmatter(raw: string): { + frontmatter: ReportFrontmatter; + body: string; +} { + const trimmed = raw.trimStart(); + + if (!trimmed.startsWith("---")) { + return { frontmatter: {}, body: trimmed }; + } + + // Find the closing `---` (must be on its own line) + const closingIndex = trimmed.indexOf("\n---", 3); + if (closingIndex === -1) { + return { frontmatter: {}, body: trimmed }; + } + + const yamlBlock = trimmed.slice(3, closingIndex).trim(); + const body = trimmed.slice(closingIndex + 4).trim(); + + let frontmatter: ReportFrontmatter = {}; + try { + const parsed: unknown = parseYaml(yamlBlock); + if (typeof parsed === "object" && parsed !== null) { + frontmatter = parsed as ReportFrontmatter; + } + } catch { + // If YAML parsing fails, treat the whole file as markdown body + return { frontmatter: {}, body: trimmed }; + } + + return { frontmatter, body }; +} + +/** + * Derive the report ID from its file path relative to the reports directory. + * + * Example: `reports/farm/thing.md` with reportsPath `reports` + * → ID: `farm/thing` + */ +export function deriveReportId(filePath: string, reportsPath: string): string { + const normalizedPrefix = reportsPath.endsWith("/") + ? reportsPath + : `${reportsPath}/`; + + let relative = filePath; + if (relative.startsWith(normalizedPrefix)) { + relative = relative.slice(normalizedPrefix.length); + } + + // Strip .md extension + if (relative.endsWith(".md")) { + relative = relative.slice(0, -3); + } + + return relative; +} + +/** + * Derive tags from directory nesting. + * + * Example: `farm/thing` → ["farm"] + * `security/api/audit` → ["security", "api"] + * `check` → [] + */ +export function deriveTagsFromPath(reportId: string): string[] { + const parts = reportId.split("/"); + // Everything except the last segment (the filename) is a tag + return parts.slice(0, -1); +} + +/** + * Derive a human-readable title from a filename. + * + * Example: `daily-check` → "Daily Check" + * `my_report_2024` → "My Report 2024" + */ +function titleFromFilename(filename: string): string { + return filename + .replace(/[-_]/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Parse a raw Markdown file into a ReportSummary (no sections). + * + * Used by REPORTS_LIST where we only need metadata. + */ +export function parseReportSummary( + raw: string, + filePath: string, + reportsPath: string, + lifecycleStatuses: LifecycleStatusMap, +): ReportSummary { + const { frontmatter } = parseFrontmatter(raw); + const id = deriveReportId(filePath, reportsPath); + const dirTags = deriveTagsFromPath(id); + + // Merge directory tags with frontmatter tags (deduped) + const frontmatterTags = Array.isArray(frontmatter.tags) + ? frontmatter.tags.map(String) + : []; + const allTags = [...new Set([...dirTags, ...frontmatterTags])]; + + // Derive title from frontmatter or filename + const filenamePart = id.split("/").pop() ?? id; + const title = frontmatter.title || titleFromFilename(filenamePart); + + const status: ReportStatus = VALID_STATUSES.has(frontmatter.status ?? "") + ? (frontmatter.status as ReportStatus) + : "info"; + + return { + id, + title, + category: frontmatter.category || "general", + status, + summary: frontmatter.summary || "", + updatedAt: frontmatter.updatedAt || new Date().toISOString(), + ...(frontmatter.source ? { source: frontmatter.source } : {}), + ...(allTags.length > 0 ? { tags: allTags } : {}), + ...(lifecycleStatuses[id] + ? { lifecycleStatus: lifecycleStatuses[id] } + : {}), + }; +} + +/** + * Parse a raw Markdown file into a full Report (with sections). + * + * Used by REPORTS_GET where we need the complete report. + */ +export function parseReport( + raw: string, + filePath: string, + reportsPath: string, + lifecycleStatuses: LifecycleStatusMap, +): Report { + const { frontmatter, body } = parseFrontmatter(raw); + const summary = parseReportSummary( + raw, + filePath, + reportsPath, + lifecycleStatuses, + ); + + // Build sections: frontmatter sections first, then markdown body + const sections: ReportSection[] = []; + + if (Array.isArray(frontmatter.sections)) { + for (const section of frontmatter.sections) { + if (isValidSection(section)) { + sections.push(section); + } + } + } + + // Append the markdown body as a final markdown section (if non-empty) + if (body.length > 0) { + sections.push({ type: "markdown", content: body }); + } + + return { + ...summary, + sections, + }; +} + +/** + * Parse the `.reports-status.json` content into a LifecycleStatusMap. + * Returns an empty map if the content is invalid. + */ +export function parseLifecycleStatuses(raw: string): LifecycleStatusMap { + try { + const parsed: unknown = JSON.parse(raw); + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { + return {}; + } + + const result: LifecycleStatusMap = {}; + const validStatuses = new Set(["unread", "read", "dismissed"]); + + for (const [key, value] of Object.entries( + parsed as Record, + )) { + if (typeof value === "string" && validStatuses.has(value)) { + result[key] = value as ReportLifecycleStatus; + } + } + + return result; + } catch { + return {}; + } +} + +// --------------------------------------------------------------------------- +// Validators +// --------------------------------------------------------------------------- + +function isValidSection(section: unknown): section is ReportSection { + if (typeof section !== "object" || section === null) return false; + + const s = section as Record; + + switch (s.type) { + case "markdown": + return typeof s.content === "string"; + case "metrics": + return Array.isArray(s.items); + case "table": + return Array.isArray(s.columns) && Array.isArray(s.rows); + default: + return false; + } +} diff --git a/github-repo-reports/server/lib/tests/report-parser.test.ts b/github-repo-reports/server/lib/tests/report-parser.test.ts new file mode 100644 index 00000000..51188b54 --- /dev/null +++ b/github-repo-reports/server/lib/tests/report-parser.test.ts @@ -0,0 +1,706 @@ +/** + * Tests for Report Parser + * + * Run with: bun test + */ + +import { describe, expect, test } from "bun:test"; +import { + deriveReportId, + deriveTagsFromPath, + parseLifecycleStatuses, + parseReport, + parseReportSummary, +} from "../report-parser.ts"; + +// --------------------------------------------------------------------------- +// deriveReportId +// --------------------------------------------------------------------------- + +describe("deriveReportId", () => { + test("strips reports path prefix and .md extension", () => { + expect(deriveReportId("reports/check.md", "reports")).toBe("check"); + }); + + test("handles nested paths", () => { + expect(deriveReportId("reports/farm/thing.md", "reports")).toBe( + "farm/thing", + ); + }); + + test("handles deeply nested paths", () => { + expect(deriveReportId("reports/security/api/audit.md", "reports")).toBe( + "security/api/audit", + ); + }); + + test("handles trailing slash in reports path", () => { + expect(deriveReportId("reports/check.md", "reports/")).toBe("check"); + }); + + test("handles custom reports path", () => { + expect(deriveReportId("docs/my-reports/check.md", "docs/my-reports")).toBe( + "check", + ); + }); + + test("handles file without .md extension gracefully", () => { + expect(deriveReportId("reports/readme.txt", "reports")).toBe("readme.txt"); + }); +}); + +// --------------------------------------------------------------------------- +// deriveTagsFromPath +// --------------------------------------------------------------------------- + +describe("deriveTagsFromPath", () => { + test("returns empty array for top-level report", () => { + expect(deriveTagsFromPath("check")).toEqual([]); + }); + + test("returns single tag for one-level nesting", () => { + expect(deriveTagsFromPath("farm/thing")).toEqual(["farm"]); + }); + + test("returns multiple tags for deep nesting", () => { + expect(deriveTagsFromPath("security/api/audit")).toEqual([ + "security", + "api", + ]); + }); + + test("returns three tags for very deep nesting", () => { + expect(deriveTagsFromPath("a/b/c/report")).toEqual(["a", "b", "c"]); + }); +}); + +// --------------------------------------------------------------------------- +// parseReportSummary +// --------------------------------------------------------------------------- + +describe("parseReportSummary", () => { + const emptyStatuses = {}; + + test("parses full frontmatter correctly", () => { + const raw = `--- +title: "Performance Report" +category: performance +status: warning +summary: "3 of 5 metrics below threshold" +source: lighthouse +tags: [homepage, mobile] +updatedAt: "2025-06-15T10:00:00Z" +--- + +Some body content. +`; + + const result = parseReportSummary( + raw, + "reports/check.md", + "reports", + emptyStatuses, + ); + + expect(result.id).toBe("check"); + expect(result.title).toBe("Performance Report"); + expect(result.category).toBe("performance"); + expect(result.status).toBe("warning"); + expect(result.summary).toBe("3 of 5 metrics below threshold"); + expect(result.source).toBe("lighthouse"); + expect(result.tags).toEqual(["homepage", "mobile"]); + expect(result.updatedAt).toBe("2025-06-15T10:00:00Z"); + expect(result.lifecycleStatus).toBeUndefined(); + }); + + test("derives title from filename when not in frontmatter", () => { + const raw = `--- +category: quality +status: passing +summary: "All good" +--- +`; + + const result = parseReportSummary( + raw, + "reports/daily-check.md", + "reports", + emptyStatuses, + ); + + expect(result.title).toBe("Daily Check"); + }); + + test("derives title from filename with underscores", () => { + const raw = `--- +status: info +summary: "Info report" +--- +`; + + const result = parseReportSummary( + raw, + "reports/my_report_2024.md", + "reports", + emptyStatuses, + ); + + expect(result.title).toBe("My Report 2024"); + }); + + test("defaults category to 'general' when not specified", () => { + const raw = `--- +title: Test +status: info +summary: test +--- +`; + + const result = parseReportSummary( + raw, + "reports/test.md", + "reports", + emptyStatuses, + ); + + expect(result.category).toBe("general"); + }); + + test("defaults status to 'info' for invalid status", () => { + const raw = `--- +title: Test +status: invalid-status +summary: test +--- +`; + + const result = parseReportSummary( + raw, + "reports/test.md", + "reports", + emptyStatuses, + ); + + expect(result.status).toBe("info"); + }); + + test("defaults status to 'info' when missing", () => { + const raw = `--- +title: Test +summary: test +--- +`; + + const result = parseReportSummary( + raw, + "reports/test.md", + "reports", + emptyStatuses, + ); + + expect(result.status).toBe("info"); + }); + + test("accepts all valid status values", () => { + for (const status of ["passing", "warning", "failing", "info"]) { + const raw = `--- +title: Test +status: ${status} +summary: test +--- +`; + const result = parseReportSummary( + raw, + "reports/test.md", + "reports", + emptyStatuses, + ); + expect(result.status).toBe(status); + } + }); + + test("merges directory tags with frontmatter tags (deduped)", () => { + const raw = `--- +title: Audit +tags: [critical, security] +status: failing +summary: Issues found +--- +`; + + const result = parseReportSummary( + raw, + "reports/security/audit.md", + "reports", + emptyStatuses, + ); + + // directory tag "security" comes first, then frontmatter tags; "security" is deduped + expect(result.tags).toEqual(["security", "critical"]); + }); + + test("directory-only tags when no frontmatter tags", () => { + const raw = `--- +title: Thing +status: info +summary: test +--- +`; + + const result = parseReportSummary( + raw, + "reports/farm/thing.md", + "reports", + emptyStatuses, + ); + + expect(result.tags).toEqual(["farm"]); + }); + + test("no tags property when top-level and no frontmatter tags", () => { + const raw = `--- +title: Root Report +status: info +summary: test +--- +`; + + const result = parseReportSummary( + raw, + "reports/root-report.md", + "reports", + emptyStatuses, + ); + + expect(result.tags).toBeUndefined(); + }); + + test("applies lifecycle status from map", () => { + const raw = `--- +title: Test +status: info +summary: test +--- +`; + + const statuses = { test: "read" as const }; + + const result = parseReportSummary( + raw, + "reports/test.md", + "reports", + statuses, + ); + + expect(result.lifecycleStatus).toBe("read"); + }); + + test("applies lifecycle status for nested report", () => { + const raw = `--- +title: Audit +status: failing +summary: Issues +--- +`; + + const statuses = { "security/audit": "dismissed" as const }; + + const result = parseReportSummary( + raw, + "reports/security/audit.md", + "reports", + statuses, + ); + + expect(result.lifecycleStatus).toBe("dismissed"); + }); + + test("no lifecycleStatus when not in map", () => { + const raw = `--- +title: Test +status: info +summary: test +--- +`; + + const result = parseReportSummary(raw, "reports/test.md", "reports", { + other: "read", + }); + + expect(result.lifecycleStatus).toBeUndefined(); + }); + + test("handles file with no frontmatter", () => { + const raw = "# Just a markdown file\n\nNo frontmatter here."; + + const result = parseReportSummary( + raw, + "reports/plain.md", + "reports", + emptyStatuses, + ); + + expect(result.id).toBe("plain"); + expect(result.title).toBe("Plain"); + expect(result.category).toBe("general"); + expect(result.status).toBe("info"); + expect(result.summary).toBe(""); + }); + + test("handles empty file", () => { + const result = parseReportSummary( + "", + "reports/empty.md", + "reports", + emptyStatuses, + ); + + expect(result.id).toBe("empty"); + expect(result.title).toBe("Empty"); + expect(result.status).toBe("info"); + }); + + test("handles frontmatter with no closing delimiter", () => { + const raw = `--- +title: Broken +status: info +This never closes +`; + + const result = parseReportSummary( + raw, + "reports/broken.md", + "reports", + emptyStatuses, + ); + + // Should treat as no frontmatter + expect(result.id).toBe("broken"); + expect(result.title).toBe("Broken"); + expect(result.status).toBe("info"); + }); + + test("source is omitted when not in frontmatter", () => { + const raw = `--- +title: Test +status: info +summary: test +--- +`; + + const result = parseReportSummary( + raw, + "reports/test.md", + "reports", + emptyStatuses, + ); + + expect(result.source).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// parseReport (full report with sections) +// --------------------------------------------------------------------------- + +describe("parseReport", () => { + const emptyStatuses = {}; + + test("parses markdown body as a section", () => { + const raw = `--- +title: Simple Report +category: quality +status: passing +summary: All good +--- + +## Overview + +Everything looks great today. +`; + + const report = parseReport( + raw, + "reports/simple.md", + "reports", + emptyStatuses, + ); + + expect(report.id).toBe("simple"); + expect(report.title).toBe("Simple Report"); + expect(report.sections).toHaveLength(1); + expect(report.sections[0].type).toBe("markdown"); + expect( + (report.sections[0] as { type: "markdown"; content: string }).content, + ).toContain("Everything looks great today."); + }); + + test("parses metrics sections from frontmatter", () => { + const raw = `--- +title: Metrics Report +status: warning +summary: Some metrics failing +sections: + - type: metrics + title: Core Web Vitals + items: + - label: LCP + value: 2.5 + unit: s + status: passing + - label: FID + value: 300 + unit: ms + status: failing +--- +`; + + const report = parseReport( + raw, + "reports/metrics.md", + "reports", + emptyStatuses, + ); + + expect(report.sections).toHaveLength(1); + + const section = report.sections[0]; + expect(section.type).toBe("metrics"); + if (section.type === "metrics") { + expect(section.title).toBe("Core Web Vitals"); + expect(section.items).toHaveLength(2); + expect(section.items[0].label).toBe("LCP"); + expect(section.items[0].value).toBe(2.5); + expect(section.items[0].unit).toBe("s"); + expect(section.items[0].status).toBe("passing"); + expect(section.items[1].label).toBe("FID"); + expect(section.items[1].value).toBe(300); + } + }); + + test("parses table sections from frontmatter", () => { + const raw = `--- +title: Table Report +status: info +summary: Resource breakdown +sections: + - type: table + title: Resources + columns: [Resource, Size, Time] + rows: + - [main.js, 150KB, 1.2s] + - [styles.css, 30KB, 0.3s] +--- +`; + + const report = parseReport( + raw, + "reports/table.md", + "reports", + emptyStatuses, + ); + + expect(report.sections).toHaveLength(1); + + const section = report.sections[0]; + expect(section.type).toBe("table"); + if (section.type === "table") { + expect(section.title).toBe("Resources"); + expect(section.columns).toEqual(["Resource", "Size", "Time"]); + expect(section.rows).toHaveLength(2); + expect(section.rows[0]).toEqual(["main.js", "150KB", "1.2s"]); + } + }); + + test("combines frontmatter sections with markdown body", () => { + const raw = `--- +title: Combined Report +status: warning +summary: Mixed content +sections: + - type: metrics + title: KPIs + items: + - label: Score + value: 85 + unit: "%" + status: passing + - type: table + title: Issues + columns: [Issue, Severity] + rows: + - [Slow API, High] +--- + +## Detailed Analysis + +Here is a longer explanation of the findings. +`; + + const report = parseReport( + raw, + "reports/combined.md", + "reports", + emptyStatuses, + ); + + expect(report.sections).toHaveLength(3); + expect(report.sections[0].type).toBe("metrics"); + expect(report.sections[1].type).toBe("table"); + expect(report.sections[2].type).toBe("markdown"); + expect( + (report.sections[2] as { type: "markdown"; content: string }).content, + ).toContain("Detailed Analysis"); + }); + + test("filters out invalid sections", () => { + const raw = `--- +title: Bad Sections +status: info +summary: test +sections: + - type: unknown + data: something + - type: markdown + content: "Valid markdown" + - type: metrics + - type: table + columns: [A] + rows: [[1]] +--- +`; + + const report = parseReport(raw, "reports/bad.md", "reports", emptyStatuses); + + // "unknown" type → invalid, "metrics" without items → invalid + // "markdown" with content → valid, "table" with columns+rows → valid + expect(report.sections).toHaveLength(2); + expect(report.sections[0].type).toBe("markdown"); + expect(report.sections[1].type).toBe("table"); + }); + + test("returns empty sections for file with no body and no frontmatter sections", () => { + const raw = `--- +title: Metadata Only +status: passing +summary: No content +--- +`; + + const report = parseReport( + raw, + "reports/metadata-only.md", + "reports", + emptyStatuses, + ); + + expect(report.sections).toEqual([]); + }); + + test("preserves report summary fields in full report", () => { + const raw = `--- +title: Full Report +category: security +status: failing +summary: Critical vulnerabilities found +source: security-scanner +tags: [critical] +updatedAt: "2025-06-15T10:00:00Z" +--- + +Details here. +`; + + const report = parseReport(raw, "reports/security/full.md", "reports", { + "security/full": "read", + }); + + expect(report.id).toBe("security/full"); + expect(report.title).toBe("Full Report"); + expect(report.category).toBe("security"); + expect(report.status).toBe("failing"); + expect(report.summary).toBe("Critical vulnerabilities found"); + expect(report.source).toBe("security-scanner"); + expect(report.tags).toEqual(["security", "critical"]); + expect(report.updatedAt).toBe("2025-06-15T10:00:00Z"); + expect(report.lifecycleStatus).toBe("read"); + expect(report.sections).toHaveLength(1); + }); + + test("handles file with no frontmatter (entire content as markdown section)", () => { + const raw = "# Just Markdown\n\nNo frontmatter at all."; + + const report = parseReport( + raw, + "reports/plain.md", + "reports", + emptyStatuses, + ); + + expect(report.sections).toHaveLength(1); + expect(report.sections[0].type).toBe("markdown"); + expect( + (report.sections[0] as { type: "markdown"; content: string }).content, + ).toBe("# Just Markdown\n\nNo frontmatter at all."); + }); +}); + +// --------------------------------------------------------------------------- +// parseLifecycleStatuses +// --------------------------------------------------------------------------- + +describe("parseLifecycleStatuses", () => { + test("parses valid JSON map", () => { + const raw = JSON.stringify({ + "farm/thing": "read", + "security/audit": "dismissed", + check: "unread", + }); + + const result = parseLifecycleStatuses(raw); + + expect(result["farm/thing"]).toBe("read"); + expect(result["security/audit"]).toBe("dismissed"); + expect(result.check).toBe("unread"); + }); + + test("ignores invalid status values", () => { + const raw = JSON.stringify({ + valid: "read", + invalid: "bogus", + also_invalid: 123, + another: "dismissed", + }); + + const result = parseLifecycleStatuses(raw); + + expect(result.valid).toBe("read"); + expect(result.another).toBe("dismissed"); + expect(result.invalid).toBeUndefined(); + expect(result.also_invalid).toBeUndefined(); + }); + + test("returns empty map for invalid JSON", () => { + expect(parseLifecycleStatuses("not json")).toEqual({}); + }); + + test("returns empty map for JSON array", () => { + expect(parseLifecycleStatuses("[1,2,3]")).toEqual({}); + }); + + test("returns empty map for JSON string", () => { + expect(parseLifecycleStatuses('"hello"')).toEqual({}); + }); + + test("returns empty map for null JSON", () => { + expect(parseLifecycleStatuses("null")).toEqual({}); + }); + + test("returns empty map for empty string", () => { + expect(parseLifecycleStatuses("")).toEqual({}); + }); + + test("handles empty object", () => { + expect(parseLifecycleStatuses("{}")).toEqual({}); + }); +}); diff --git a/github-repo-reports/server/main.ts b/github-repo-reports/server/main.ts new file mode 100644 index 00000000..fdbaf098 --- /dev/null +++ b/github-repo-reports/server/main.ts @@ -0,0 +1,116 @@ +/** + * GitHub Repo Reports — MCP Server + * + * Implements the Reports Binding by reading Markdown files with YAML + * frontmatter from a configurable GitHub repository. Uses GitHub App + * OAuth for authentication and supports directory nesting as tags. + * + * Required tools: REPORTS_LIST, REPORTS_GET + * Optional tools: REPORTS_UPDATE_STATUS + */ + +import { withRuntime } from "@decocms/runtime"; +import { serve } from "@decocms/mcps-shared/serve"; + +import { exchangeCodeForToken } from "./lib/github-client.ts"; +import { tools } from "./tools/index.ts"; +import { type Env, StateSchema } from "./types/env.ts"; + +export type { Env }; + +// --------------------------------------------------------------------------- +// GitHub OAuth configuration from environment +// --------------------------------------------------------------------------- + +const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || ""; +const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || ""; +const GITHUB_APP_NAME = process.env.GITHUB_APP_NAME || "decocms-bot"; + +// --------------------------------------------------------------------------- +// MCP Runtime +// --------------------------------------------------------------------------- + +const runtime = withRuntime({ + oauth: { + mode: "PKCE", + authorizationServer: "https://github.com", + + /** + * Generate the authorization URL for GitHub App installation. + * + * Uses /installations/select_target so the user can choose which + * organisation / account (and repositories) to grant access to. + */ + authorizationUrl: (callbackUrl) => { + const url = new URL( + `https://github.com/apps/${GITHUB_APP_NAME}/installations/select_target`, + ); + + // Preserve the CSRF state parameter from the callback URL + const callbackUrlObj = new URL(callbackUrl); + const state = callbackUrlObj.searchParams.get("state"); + if (state) { + url.searchParams.set("state", state); + } + + return url.toString(); + }, + + /** + * Exchange the authorization code for a GitHub access token. + */ + exchangeCode: async ({ code }) => { + if (!GITHUB_CLIENT_ID || !GITHUB_CLIENT_SECRET) { + throw new Error( + "GitHub OAuth credentials not configured. " + + "Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET environment variables.", + ); + } + + const tokenResponse = await exchangeCodeForToken( + code, + GITHUB_CLIENT_ID, + GITHUB_CLIENT_SECRET, + ); + + console.log("[github-repo-reports] Token exchange successful"); + + return { + access_token: tokenResponse.access_token, + token_type: tokenResponse.token_type, + }; + }, + }, + + configuration: { + state: StateSchema, + }, + + tools, + prompts: [], +}); + +// --------------------------------------------------------------------------- +// Start server +// --------------------------------------------------------------------------- + +serve(runtime.fetch); + +console.log(` +GitHub Repo Reports MCP Server Started + +Environment Variables: + GITHUB_CLIENT_ID - GitHub App Client ID + GITHUB_CLIENT_SECRET - GitHub App Client Secret + GITHUB_APP_NAME - GitHub App name (default: decocms-bot) + +Configuration (StateSchema): + REPO - Target repository ("owner/repo") + PATH - Reports directory path (default: "reports") + BRANCH - Git branch (default: "reports") + +Reports Binding Tools: + REPORTS_LIST - List reports with optional filters + REPORTS_GET - Get a single report with full content + REPORTS_UPDATE_STATUS - Update lifecycle status (unread/read/dismissed) +`); diff --git a/github-repo-reports/server/tools/index.ts b/github-repo-reports/server/tools/index.ts new file mode 100644 index 00000000..886b59d4 --- /dev/null +++ b/github-repo-reports/server/tools/index.ts @@ -0,0 +1,18 @@ +/** + * Tools Export + * + * Exports all tools that implement the Reports Binding: + * - REPORTS_LIST (required) + * - REPORTS_GET (required) + * - REPORTS_UPDATE_STATUS (optional) + */ + +import { createReportsGetTool } from "./reports-get.ts"; +import { createReportsListTool } from "./reports-list.ts"; +import { createReportsUpdateStatusTool } from "./reports-update-status.ts"; + +export const tools = [ + createReportsListTool, + createReportsGetTool, + createReportsUpdateStatusTool, +]; diff --git a/github-repo-reports/server/tools/reports-get.ts b/github-repo-reports/server/tools/reports-get.ts new file mode 100644 index 00000000..0c87adff --- /dev/null +++ b/github-repo-reports/server/tools/reports-get.ts @@ -0,0 +1,133 @@ +/** + * REPORTS_GET Tool + * + * Retrieves a single report by ID with full sections. + * Fetches the Markdown file from GitHub, parses frontmatter + body, + * and merges lifecycle status. + */ + +import { createPrivateTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import type { Env } from "../types/env.ts"; +import { + getGitHubClient, + getRepoConfig, + getStatusFilePath, +} from "../lib/config.ts"; +import { + type LifecycleStatusMap, + parseLifecycleStatuses, + parseReport, +} from "../lib/report-parser.ts"; + +const ReportStatusEnum = z.enum(["passing", "warning", "failing", "info"]); + +const MetricItemSchema = z.object({ + label: z.string(), + value: z.union([z.number(), z.string()]), + unit: z.string().optional(), + previousValue: z.union([z.number(), z.string()]).optional(), + status: ReportStatusEnum.optional(), +}); + +const ReportSectionSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("markdown"), + content: z.string(), + }), + z.object({ + type: z.literal("metrics"), + title: z.string().optional(), + items: z.array(MetricItemSchema), + }), + z.object({ + type: z.literal("table"), + title: z.string().optional(), + columns: z.array(z.string()), + rows: z.array(z.array(z.union([z.string(), z.number(), z.null()]))), + }), +]); + +export const createReportsGetTool = (env: Env) => + createPrivateTool({ + id: "REPORTS_GET", + description: + "Get a specific report with full content including all sections.", + inputSchema: z.object({ + id: z + .string() + .describe("Report identifier (relative path without .md extension)"), + }), + outputSchema: z.object({ + id: z.string(), + title: z.string(), + category: z.string(), + status: ReportStatusEnum, + summary: z.string(), + updatedAt: z.string(), + source: z.string().optional(), + tags: z.array(z.string()).optional(), + lifecycleStatus: z.enum(["unread", "read", "dismissed"]).optional(), + sections: z.array(ReportSectionSchema), + }), + execute: async ({ context }) => { + const config = getRepoConfig(env); + const client = getGitHubClient(env); + + const repoParams = { + owner: config.owner, + repo: config.repo, + branch: config.branch, + }; + + // Build the full file path from the report ID + const filePath = `${config.path}/${context.id}.md`; + + // Fetch the file content and lifecycle statuses in parallel + const [fileResult, lifecycleStatuses] = await Promise.all([ + client.getFileContent(repoParams, filePath), + fetchLifecycleStatuses(client, config), + ]); + + if (!fileResult) { + throw new Error(`Report "${context.id}" not found`); + } + + const report = parseReport( + fileResult.content, + filePath, + config.path, + lifecycleStatuses, + ); + + return report; + }, + }); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface RepoConfig { + owner: string; + repo: string; + branch: string; + path: string; +} + +async function fetchLifecycleStatuses( + client: ReturnType, + config: RepoConfig, +): Promise { + const statusPath = getStatusFilePath(config.path); + const file = await client.getFileContent( + { owner: config.owner, repo: config.repo, branch: config.branch }, + statusPath, + ); + + if (!file) { + return {}; + } + + return parseLifecycleStatuses(file.content); +} diff --git a/github-repo-reports/server/tools/reports-list.ts b/github-repo-reports/server/tools/reports-list.ts new file mode 100644 index 00000000..9adf2f89 --- /dev/null +++ b/github-repo-reports/server/tools/reports-list.ts @@ -0,0 +1,154 @@ +/** + * REPORTS_LIST Tool + * + * Lists available reports with optional filtering by category and status. + * Fetches all Markdown files from the configured GitHub repo directory, + * parses YAML frontmatter for metadata, and merges lifecycle statuses. + */ + +import { createPrivateTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import type { Env } from "../types/env.ts"; +import { + getGitHubClient, + getRepoConfig, + getStatusFilePath, +} from "../lib/config.ts"; +import { + type LifecycleStatusMap, + type ReportStatus, + type ReportSummary, + parseLifecycleStatuses, + parseReportSummary, +} from "../lib/report-parser.ts"; + +const ReportStatusEnum = z.enum(["passing", "warning", "failing", "info"]); + +export const createReportsListTool = (env: Env) => + createPrivateTool({ + id: "REPORTS_LIST", + description: + "List available reports with optional filters. Returns report summaries (metadata only, no sections).", + inputSchema: z.object({ + category: z + .string() + .optional() + .describe("Filter by category (e.g., 'performance', 'security')"), + status: ReportStatusEnum.optional().describe( + "Filter by report status (passing, warning, failing, info)", + ), + }), + outputSchema: z.object({ + reports: z.array( + z.object({ + id: z.string(), + title: z.string(), + category: z.string(), + status: ReportStatusEnum, + summary: z.string(), + updatedAt: z.string(), + source: z.string().optional(), + tags: z.array(z.string()).optional(), + lifecycleStatus: z.enum(["unread", "read", "dismissed"]).optional(), + }), + ), + }), + execute: async ({ context }) => { + const config = getRepoConfig(env); + const client = getGitHubClient(env); + + const repoParams = { + owner: config.owner, + repo: config.repo, + branch: config.branch, + }; + + // Fetch the directory tree and lifecycle statuses in parallel + const [treeEntries, lifecycleStatuses] = await Promise.all([ + client.listMarkdownFiles(repoParams, config.path), + fetchLifecycleStatuses(client, config), + ]); + + if (treeEntries.length === 0) { + return { reports: [] }; + } + + // Fetch all file contents in parallel via the Blob API + const contentPromises = treeEntries.map(async (entry) => { + try { + const content = await client.getBlobContent( + config.owner, + config.repo, + entry.sha, + ); + return { path: entry.path, content }; + } catch (error) { + console.error( + `[reports] Failed to fetch blob for ${entry.path}:`, + error, + ); + return null; + } + }); + + const fileContents = (await Promise.all(contentPromises)).filter( + (item): item is { path: string; content: string } => item !== null, + ); + + // Parse each file into a ReportSummary + let reports: ReportSummary[] = fileContents.map((file) => + parseReportSummary( + file.content, + file.path, + config.path, + lifecycleStatuses, + ), + ); + + // Apply filters + if (context.category) { + reports = reports.filter((r) => r.category === context.category); + } + if (context.status) { + reports = reports.filter( + (r) => r.status === (context.status as ReportStatus), + ); + } + + // Sort by updatedAt descending (most recent first) + reports.sort( + (a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); + + return { reports }; + }, + }); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface RepoConfig { + owner: string; + repo: string; + branch: string; + path: string; +} + +async function fetchLifecycleStatuses( + client: ReturnType, + config: RepoConfig, +): Promise { + const statusPath = getStatusFilePath(config.path); + const file = await client.getFileContent( + { owner: config.owner, repo: config.repo, branch: config.branch }, + statusPath, + ); + + if (!file) { + return {}; + } + + return parseLifecycleStatuses(file.content); +} diff --git a/github-repo-reports/server/tools/reports-update-status.ts b/github-repo-reports/server/tools/reports-update-status.ts new file mode 100644 index 00000000..b66dbe55 --- /dev/null +++ b/github-repo-reports/server/tools/reports-update-status.ts @@ -0,0 +1,85 @@ +/** + * REPORTS_UPDATE_STATUS Tool (Optional) + * + * Updates the lifecycle status of a report (unread / read / dismissed). + * Persists state in a `.reports-status.json` file within the reports + * directory in the GitHub repository. + */ + +import { createPrivateTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import type { Env } from "../types/env.ts"; +import { + getGitHubClient, + getRepoConfig, + getStatusFilePath, +} from "../lib/config.ts"; +import { + type LifecycleStatusMap, + type ReportLifecycleStatus, + parseLifecycleStatuses, +} from "../lib/report-parser.ts"; + +export const createReportsUpdateStatusTool = (env: Env) => + createPrivateTool({ + id: "REPORTS_UPDATE_STATUS", + description: + "Update the lifecycle status of a report (unread, read, or dismissed).", + inputSchema: z.object({ + reportId: z.string().describe("Report identifier"), + lifecycleStatus: z + .enum(["unread", "read", "dismissed"]) + .describe("New lifecycle status"), + }), + outputSchema: z.object({ + success: z.boolean(), + message: z.string().optional(), + }), + execute: async ({ context }) => { + const config = getRepoConfig(env); + const client = getGitHubClient(env); + + const repoParams = { + owner: config.owner, + repo: config.repo, + branch: config.branch, + }; + + const statusPath = getStatusFilePath(config.path); + + // Fetch the current status file (may not exist yet) + const existing = await client.getFileContent(repoParams, statusPath); + + let statuses: LifecycleStatusMap = {}; + if (existing) { + statuses = parseLifecycleStatuses(existing.content); + } + + // Update the entry + const newStatus = context.lifecycleStatus as ReportLifecycleStatus; + + // If setting to "unread" (the default), remove the entry to keep the file small + if (newStatus === "unread") { + delete statuses[context.reportId]; + } else { + statuses[context.reportId] = newStatus; + } + + // Serialize and commit back + const content = JSON.stringify(statuses, null, 2); + const commitMessage = `chore(reports): update status for ${context.reportId} → ${context.lifecycleStatus}`; + + await client.putFileContent( + repoParams, + statusPath, + content, + commitMessage, + existing?.sha, + ); + + return { + success: true, + message: `Report "${context.reportId}" marked as ${context.lifecycleStatus}`, + }; + }, + }); diff --git a/github-repo-reports/server/types/env.ts b/github-repo-reports/server/types/env.ts new file mode 100644 index 00000000..89dd4bb8 --- /dev/null +++ b/github-repo-reports/server/types/env.ts @@ -0,0 +1,51 @@ +/** + * Environment Type Definitions for GitHub Repo Reports MCP + * + * Defines the StateSchema for user configuration and the Env type + * used throughout the MCP server. + */ + +import type { DefaultEnv } from "@decocms/runtime"; +import { z } from "zod"; + +/** + * State Schema - Configuration form for the MCP + * + * Users fill this form when installing the MCP in Mesh. + * Configures which GitHub repository and branch to read reports from. + */ +export const StateSchema = z.object({ + /** + * Target repository in "owner/repo" format. + * The GitHub App must have access to this repository. + */ + REPO: z + .string() + .describe( + 'Target repository in "owner/repo" format (e.g., "acme/my-reports")', + ), + + /** + * Path to the reports directory within the repository. + * Reports are stored as Markdown files with YAML frontmatter + * under this directory. Subdirectories become tags. + */ + PATH: z + .string() + .default("reports") + .describe("Path to the reports directory in the repository"), + + /** + * Git branch to read reports from. + * Defaults to "reports" — a dedicated branch for report storage. + */ + BRANCH: z + .string() + .default("reports") + .describe("Git branch to read reports from"), +}); + +/** + * Environment type combining runtime context with our StateSchema. + */ +export type Env = DefaultEnv; diff --git a/github-repo-reports/tsconfig.json b/github-repo-reports/tsconfig.json new file mode 100644 index 00000000..b590c229 --- /dev/null +++ b/github-repo-reports/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2023", "ES2024"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "verbatimModuleSyntax": false, + "moduleDetection": "force", + "noEmit": true, + "allowJs": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + + /* Path Aliases */ + "baseUrl": ".", + "paths": { + "server/*": ["./server/*"] + } + }, + "include": ["server"] +} diff --git a/package.json b/package.json index 6f37482b..880b6e88 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "discord-read", "gemini-pro-vision", "github", + "github-repo-reports", "google-apps-script", "google-big-query", "google-calendar", @@ -62,12 +63,12 @@ "template-minimal", "tiktok-ads", "veo", - "vtex-docs", + "virtual-try-on", "vtex", + "vtex-docs", "whatsapp", "whatsapp-management", - "whisper", - "virtual-try-on" + "whisper" ], "dependencies": { "@types/node": "^24.10.0",