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",