Merge pull request 'feature/mcp-and-api-keys' (#11) from feature/mcp-and-api-keys into main

Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
2026-03-16 16:46:26 +00:00
17 changed files with 1641 additions and 2 deletions

View File

@@ -8,6 +8,7 @@
"name": "timetracker-backend",
"version": "1.0.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
"@prisma/client": "^6.19.2",
"@quixo3/prisma-session-store": "^3.1.19",
"cors": "^2.8.5",
@@ -471,6 +472,367 @@
"node": ">=18"
}
},
"node_modules/@hono/node-server": {
"version": "1.19.11",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
},
"peerDependencies": {
"hono": "^4"
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.27.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz",
"integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==",
"license": "MIT",
"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.2.1",
"express-rate-limit": "^8.2.1",
"hono": "^4.11.4",
"jose": "^6.1.3",
"json-schema-typed": "^8.0.2",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.25 || ^4.0",
"zod-to-json-schema": "^3.25.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@cfworker/json-schema": "^4.1.1",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"@cfworker/json-schema": {
"optional": true
},
"zod": {
"optional": false
}
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/jose": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz",
"integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
@@ -771,6 +1133,39 @@
"node": ">= 0.6"
}
},
"node_modules/ajv": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -983,6 +1378,20 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -1193,6 +1602,27 @@
"node": ">= 0.6"
}
},
"node_modules/eventsource": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
"license": "MIT",
"dependencies": {
"eventsource-parser": "^3.0.1"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/eventsource-parser": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
"integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
@@ -1239,6 +1669,24 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
"integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
"license": "MIT",
"dependencies": {
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express-session": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
@@ -1292,6 +1740,28 @@
"node": ">=8.0.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -1456,6 +1926,15 @@
"node": ">= 0.4"
}
},
"node_modules/hono": {
"version": "4.12.8",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz",
"integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1494,6 +1973,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1503,6 +1991,18 @@
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -1522,6 +2022,18 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/json-schema-typed": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause"
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -1808,6 +2320,15 @@
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
@@ -1832,6 +2353,15 @@
"node": ">= 0.8"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
@@ -1852,6 +2382,15 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/pkce-challenge": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
"license": "MIT",
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/pkg-types": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
@@ -1993,6 +2532,15 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@@ -2003,6 +2551,55 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/router/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/router/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/router/node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -2092,6 +2689,27 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@@ -2321,6 +2939,27 @@
"node": ">= 0.8"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
@@ -2335,6 +2974,15 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.25.1",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.25 || ^4"
}
}
}
}

View File

@@ -10,6 +10,7 @@
"db:seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
"@prisma/client": "^6.19.2",
"@quixo3/prisma-session-store": "^3.1.19",
"cors": "^2.8.5",

View File

@@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "api_keys" (
"id" TEXT NOT NULL,
"name" VARCHAR(255) NOT NULL,
"key_hash" VARCHAR(64) NOT NULL,
"prefix" VARCHAR(16) NOT NULL,
"last_used_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"user_id" VARCHAR(255) NOT NULL,
CONSTRAINT "api_keys_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "api_keys_key_hash_key" ON "api_keys"("key_hash");
-- CreateIndex
CREATE INDEX "api_keys_user_id_idx" ON "api_keys"("user_id");
-- AddForeignKey
ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -20,6 +20,7 @@ model User {
timeEntries TimeEntry[]
ongoingTimer OngoingTimer?
clientTargets ClientTarget[]
apiKeys ApiKey[]
@@map("users")
}
@@ -151,3 +152,18 @@ model Session {
@@map("sessions")
}
model ApiKey {
id String @id @default(uuid())
name String @db.VarChar(255)
keyHash String @unique @map("key_hash") @db.VarChar(64) // SHA-256 hex
prefix String @db.VarChar(16) // first chars of raw key for display
lastUsedAt DateTime? @map("last_used_at")
createdAt DateTime @default(now()) @map("created_at")
userId String @map("user_id") @db.VarChar(255)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("api_keys")
}

View File

@@ -13,6 +13,8 @@ import projectRoutes from "./routes/project.routes";
import timeEntryRoutes from "./routes/timeEntry.routes";
import timerRoutes from "./routes/timer.routes";
import clientTargetRoutes from "./routes/clientTarget.routes";
import apiKeyRoutes from "./routes/apiKey.routes";
import mcpRoutes from "./routes/mcp.routes";
async function main() {
// Validate configuration
@@ -70,6 +72,8 @@ async function main() {
app.use("/time-entries", timeEntryRoutes);
app.use("/timer", timerRoutes);
app.use("/client-targets", clientTargetRoutes);
app.use("/api-keys", apiKeyRoutes);
app.use("/mcp", mcpRoutes);
// Error handling
app.use(notFoundHandler);

View File

@@ -2,6 +2,9 @@ import { Request, Response, NextFunction } from 'express';
import { prisma } from '../prisma/client';
import type { AuthenticatedRequest, AuthenticatedUser } from '../types';
import { verifyBackendJwt } from '../auth/jwt';
import { ApiKeyService } from '../services/apiKey.service';
const apiKeyService = new ApiKeyService();
export async function requireAuth(
req: AuthenticatedRequest,
@@ -17,11 +20,33 @@ export async function requireAuth(
return next();
}
// 2. Bearer JWT auth (iOS / native clients)
// 2. Bearer token auth (JWT or API key)
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7);
console.log(`${tag} -> Bearer token present (first 20 chars: ${token.slice(0, 20)}…)`);
// 2a. API key — detected by the "sk_" prefix
if (token.startsWith('sk_')) {
try {
const user = await apiKeyService.verify(token);
if (!user) {
console.warn(`${tag} -> API key verification failed: key not found`);
res.status(401).json({ error: 'Unauthorized: invalid API key' });
return;
}
req.user = user;
console.log(`${tag} -> API key auth OK (user: ${req.user.id})`);
return next();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`${tag} -> API key verification error: ${message}`);
res.status(401).json({ error: `Unauthorized: ${message}` });
return;
}
}
// 2b. JWT (iOS / native clients)
try {
req.user = verifyBackendJwt(token);
console.log(`${tag} -> JWT auth OK (user: ${req.user.id})`);

View File

@@ -0,0 +1,51 @@
import { Router } from 'express';
import { requireAuth } from '../middleware/auth';
import { validateBody, validateParams } from '../middleware/validation';
import { ApiKeyService } from '../services/apiKey.service';
import { CreateApiKeySchema, IdSchema } from '../schemas';
import type { AuthenticatedRequest } from '../types';
const router = Router();
const apiKeyService = new ApiKeyService();
// GET /api-keys - List user's API keys
router.get('/', requireAuth, async (req: AuthenticatedRequest, res, next) => {
try {
const keys = await apiKeyService.list(req.user!.id);
res.json(keys);
} catch (error) {
next(error);
}
});
// POST /api-keys - Create a new API key
router.post(
'/',
requireAuth,
validateBody(CreateApiKeySchema),
async (req: AuthenticatedRequest, res, next) => {
try {
const created = await apiKeyService.create(req.user!.id, req.body.name);
res.status(201).json(created);
} catch (error) {
next(error);
}
}
);
// DELETE /api-keys/:id - Revoke an API key
router.delete(
'/:id',
requireAuth,
validateParams(IdSchema),
async (req: AuthenticatedRequest, res, next) => {
try {
await apiKeyService.delete(req.params.id, req.user!.id);
res.status(204).send();
} catch (error) {
next(error);
}
}
);
export default router;

View File

@@ -0,0 +1,455 @@
import { Router, Request, Response } from 'express';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { z } from 'zod';
import { requireAuth } from '../middleware/auth';
import type { AuthenticatedRequest, AuthenticatedUser } from '../types';
import { ClientService } from '../services/client.service';
import { ProjectService } from '../services/project.service';
import { TimeEntryService } from '../services/timeEntry.service';
import { TimerService } from '../services/timer.service';
import { ClientTargetService } from '../services/clientTarget.service';
const router = Router();
// Service instances — shared, stateless
const clientService = new ClientService();
const projectService = new ProjectService();
const timeEntryService = new TimeEntryService();
const timerService = new TimerService();
const clientTargetService = new ClientTargetService();
/**
* Build and return a fresh stateless McpServer pre-populated with all tools
* scoped to the given authenticated user.
*/
function buildMcpServer(user: AuthenticatedUser): McpServer {
const server = new McpServer({
name: 'timetracker',
version: '1.0.0',
});
const userId = user.id;
// -------------------------------------------------------------------------
// Clients
// -------------------------------------------------------------------------
server.registerTool(
'list_clients',
{
description: 'List all clients for the authenticated user.',
inputSchema: {},
},
async () => {
const clients = await clientService.findAll(userId);
return { content: [{ type: 'text', text: JSON.stringify(clients, null, 2) }] };
}
);
server.registerTool(
'create_client',
{
description: 'Create a new client.',
inputSchema: {
name: z.string().min(1).max(255).describe('Client name'),
description: z.string().max(1000).optional().describe('Optional description'),
},
},
async ({ name, description }) => {
const client = await clientService.create(userId, { name, description });
return { content: [{ type: 'text', text: JSON.stringify(client, null, 2) }] };
}
);
server.registerTool(
'update_client',
{
description: 'Update an existing client.',
inputSchema: {
id: z.string().uuid().describe('Client ID'),
name: z.string().min(1).max(255).optional().describe('New name'),
description: z.string().max(1000).optional().describe('New description'),
},
},
async ({ id, name, description }) => {
const client = await clientService.update(id, userId, { name, description });
return { content: [{ type: 'text', text: JSON.stringify(client, null, 2) }] };
}
);
server.registerTool(
'delete_client',
{
description: 'Soft-delete a client (and its projects).',
inputSchema: {
id: z.string().uuid().describe('Client ID'),
},
},
async ({ id }) => {
await clientService.delete(id, userId);
return { content: [{ type: 'text', text: `Client ${id} deleted.` }] };
}
);
// -------------------------------------------------------------------------
// Projects
// -------------------------------------------------------------------------
server.registerTool(
'list_projects',
{
description: 'List all projects, optionally filtered by clientId.',
inputSchema: {
clientId: z.string().uuid().optional().describe('Filter by client ID'),
},
},
async ({ clientId }) => {
const projects = await projectService.findAll(userId, clientId);
return { content: [{ type: 'text', text: JSON.stringify(projects, null, 2) }] };
}
);
server.registerTool(
'create_project',
{
description: 'Create a new project under a client.',
inputSchema: {
name: z.string().min(1).max(255).describe('Project name'),
clientId: z.string().uuid().describe('Client ID the project belongs to'),
description: z.string().max(1000).optional().describe('Optional description'),
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional().describe('Hex color code, e.g. #FF5733'),
},
},
async ({ name, clientId, description, color }) => {
const project = await projectService.create(userId, { name, clientId, description, color });
return { content: [{ type: 'text', text: JSON.stringify(project, null, 2) }] };
}
);
server.registerTool(
'update_project',
{
description: 'Update an existing project.',
inputSchema: {
id: z.string().uuid().describe('Project ID'),
name: z.string().min(1).max(255).optional().describe('New name'),
description: z.string().max(1000).optional().describe('New description'),
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).nullable().optional().describe('Hex color or null to clear'),
clientId: z.string().uuid().optional().describe('Move project to a different client'),
},
},
async ({ id, name, description, color, clientId }) => {
const project = await projectService.update(id, userId, { name, description, color, clientId });
return { content: [{ type: 'text', text: JSON.stringify(project, null, 2) }] };
}
);
server.registerTool(
'delete_project',
{
description: 'Soft-delete a project.',
inputSchema: {
id: z.string().uuid().describe('Project ID'),
},
},
async ({ id }) => {
await projectService.delete(id, userId);
return { content: [{ type: 'text', text: `Project ${id} deleted.` }] };
}
);
// -------------------------------------------------------------------------
// Time entries
// -------------------------------------------------------------------------
server.registerTool(
'list_time_entries',
{
description: 'List time entries with optional filters. Returns paginated results.',
inputSchema: {
startDate: z.string().datetime().optional().describe('Filter entries starting at or after this ISO datetime'),
endDate: z.string().datetime().optional().describe('Filter entries starting at or before this ISO datetime'),
projectId: z.string().uuid().optional().describe('Filter by project ID'),
clientId: z.string().uuid().optional().describe('Filter by client ID'),
page: z.number().int().min(1).optional().default(1).describe('Page number (default 1)'),
limit: z.number().int().min(1).max(100).optional().default(50).describe('Results per page (max 100, default 50)'),
},
},
async (filters) => {
const result = await timeEntryService.findAll(userId, filters);
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
}
);
server.registerTool(
'create_time_entry',
{
description: 'Create a manual time entry.',
inputSchema: {
projectId: z.string().uuid().describe('Project ID'),
startTime: z.string().datetime().describe('Start time as ISO datetime string'),
endTime: z.string().datetime().describe('End time as ISO datetime string'),
breakMinutes: z.number().int().min(0).optional().describe('Break duration in minutes (default 0)'),
description: z.string().max(1000).optional().describe('Optional description'),
},
},
async ({ projectId, startTime, endTime, breakMinutes, description }) => {
const entry = await timeEntryService.create(userId, { projectId, startTime, endTime, breakMinutes, description });
return { content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }] };
}
);
server.registerTool(
'update_time_entry',
{
description: 'Update an existing time entry.',
inputSchema: {
id: z.string().uuid().describe('Time entry ID'),
startTime: z.string().datetime().optional().describe('New start time'),
endTime: z.string().datetime().optional().describe('New end time'),
breakMinutes: z.number().int().min(0).optional().describe('New break duration in minutes'),
description: z.string().max(1000).optional().describe('New description'),
projectId: z.string().uuid().optional().describe('Move to a different project'),
},
},
async ({ id, ...data }) => {
const entry = await timeEntryService.update(id, userId, data);
return { content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }] };
}
);
server.registerTool(
'delete_time_entry',
{
description: 'Delete a time entry.',
inputSchema: {
id: z.string().uuid().describe('Time entry ID'),
},
},
async ({ id }) => {
await timeEntryService.delete(id, userId);
return { content: [{ type: 'text', text: `Time entry ${id} deleted.` }] };
}
);
server.registerTool(
'get_statistics',
{
description: 'Get aggregated time-tracking statistics, grouped by project and client.',
inputSchema: {
startDate: z.string().datetime().optional().describe('Filter from this ISO datetime'),
endDate: z.string().datetime().optional().describe('Filter until this ISO datetime'),
projectId: z.string().uuid().optional().describe('Filter by project ID'),
clientId: z.string().uuid().optional().describe('Filter by client ID'),
},
},
async (filters) => {
const stats = await timeEntryService.getStatistics(userId, filters);
return { content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }] };
}
);
// -------------------------------------------------------------------------
// Timer
// -------------------------------------------------------------------------
server.registerTool(
'get_timer',
{
description: 'Get the current running timer, or null if none is active.',
inputSchema: {},
},
async () => {
const timer = await timerService.getOngoingTimer(userId);
return { content: [{ type: 'text', text: JSON.stringify(timer, null, 2) }] };
}
);
server.registerTool(
'start_timer',
{
description: 'Start a new timer. Fails if a timer is already running.',
inputSchema: {
projectId: z.string().uuid().optional().describe('Assign the timer to a project (can be set later)'),
},
},
async ({ projectId }) => {
const timer = await timerService.start(userId, { projectId });
return { content: [{ type: 'text', text: JSON.stringify(timer, null, 2) }] };
}
);
server.registerTool(
'stop_timer',
{
description: 'Stop the running timer and save it as a time entry. A project must be assigned.',
inputSchema: {
projectId: z.string().uuid().optional().describe('Assign/override the project before stopping'),
},
},
async ({ projectId }) => {
const entry = await timerService.stop(userId, { projectId });
return { content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }] };
}
);
server.registerTool(
'cancel_timer',
{
description: 'Cancel the running timer without saving a time entry.',
inputSchema: {},
},
async () => {
await timerService.cancel(userId);
return { content: [{ type: 'text', text: 'Timer cancelled.' }] };
}
);
// -------------------------------------------------------------------------
// Client targets
// -------------------------------------------------------------------------
server.registerTool(
'list_client_targets',
{
description: 'List all client hour targets with computed balance for each period.',
inputSchema: {},
},
async () => {
const targets = await clientTargetService.findAll(userId);
return { content: [{ type: 'text', text: JSON.stringify(targets, null, 2) }] };
}
);
server.registerTool(
'create_client_target',
{
description: 'Create a new hour target for a client.',
inputSchema: {
clientId: z.string().uuid().describe('Client ID'),
targetHours: z.number().positive().max(168).describe('Target hours per period'),
periodType: z.enum(['weekly', 'monthly']).describe('Period type: weekly or monthly'),
workingDays: z.array(z.enum(['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'])).min(1).describe('Working days, e.g. ["MON","TUE","WED","THU","FRI"]'),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('Start date in YYYY-MM-DD format'),
},
},
async (data) => {
const target = await clientTargetService.create(userId, data);
return { content: [{ type: 'text', text: JSON.stringify(target, null, 2) }] };
}
);
server.registerTool(
'update_client_target',
{
description: 'Update an existing client hour target.',
inputSchema: {
id: z.string().uuid().describe('Target ID'),
targetHours: z.number().positive().max(168).optional().describe('New target hours per period'),
periodType: z.enum(['weekly', 'monthly']).optional().describe('New period type'),
workingDays: z.array(z.enum(['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'])).min(1).optional().describe('New working days'),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('New start date in YYYY-MM-DD'),
},
},
async ({ id, ...data }) => {
const target = await clientTargetService.update(id, userId, data);
return { content: [{ type: 'text', text: JSON.stringify(target, null, 2) }] };
}
);
server.registerTool(
'delete_client_target',
{
description: 'Delete a client hour target.',
inputSchema: {
id: z.string().uuid().describe('Target ID'),
},
},
async ({ id }) => {
await clientTargetService.delete(id, userId);
return { content: [{ type: 'text', text: `Client target ${id} deleted.` }] };
}
);
server.registerTool(
'add_target_correction',
{
description: 'Add a manual hour correction to a client target (e.g. for holidays or overtime carry-over).',
inputSchema: {
targetId: z.string().uuid().describe('Client target ID'),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('Date of correction in YYYY-MM-DD format'),
hours: z.number().min(-1000).max(1000).describe('Hours to add (negative to deduct)'),
description: z.string().max(255).optional().describe('Optional reason for the correction'),
},
},
async ({ targetId, date, hours, description }) => {
const correction = await clientTargetService.addCorrection(targetId, userId, { date, hours, description });
return { content: [{ type: 'text', text: JSON.stringify(correction, null, 2) }] };
}
);
server.registerTool(
'delete_target_correction',
{
description: 'Delete a manual hour correction from a client target.',
inputSchema: {
targetId: z.string().uuid().describe('Client target ID'),
correctionId: z.string().uuid().describe('Correction ID'),
},
},
async ({ targetId, correctionId }) => {
await clientTargetService.deleteCorrection(targetId, correctionId, userId);
return { content: [{ type: 'text', text: `Correction ${correctionId} deleted.` }] };
}
);
return server;
}
// ---------------------------------------------------------------------------
// Route handler — one fresh McpServer + transport per request (stateless)
// ---------------------------------------------------------------------------
async function handleMcpRequest(req: AuthenticatedRequest, res: Response): Promise<void> {
const user = req.user!;
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
const mcpServer = buildMcpServer(user);
// Ensure the server is cleaned up when the response finishes
res.on('close', () => {
transport.close().catch(() => undefined);
mcpServer.close().catch(() => undefined);
});
await mcpServer.connect(transport);
await transport.handleRequest(req as unknown as Request, res, req.body);
}
// GET /mcp — SSE stream for server-initiated messages
router.get('/', requireAuth, (req: AuthenticatedRequest, res: Response) => {
handleMcpRequest(req, res).catch((err) => {
console.error('[MCP] GET error:', err);
if (!res.headersSent) {
res.status(500).json({ error: 'Internal server error' });
}
});
});
// POST /mcp — JSON-RPC requests
router.post('/', requireAuth, (req: AuthenticatedRequest, res: Response) => {
handleMcpRequest(req, res).catch((err) => {
console.error('[MCP] POST error:', err);
if (!res.headersSent) {
res.status(500).json({ error: 'Internal server error' });
}
});
});
// DELETE /mcp — session termination (stateless: always 405)
router.delete('/', (_req, res: Response) => {
res.status(405).json({ error: 'Sessions are not supported (stateless mode)' });
});
export default router;

View File

@@ -95,3 +95,7 @@ export const CreateCorrectionSchema = z.object({
hours: z.number().min(-1000).max(1000),
description: z.string().max(255).optional(),
});
export const CreateApiKeySchema = z.object({
name: z.string().min(1).max(255),
});

View File

@@ -0,0 +1,99 @@
import { createHash, randomUUID } from 'crypto';
import { prisma } from '../prisma/client';
import { NotFoundError } from '../errors/AppError';
import type { AuthenticatedUser } from '../types';
const KEY_PREFIX_LENGTH = 12; // chars shown in UI
function hashKey(rawKey: string): string {
return createHash('sha256').update(rawKey).digest('hex');
}
function generateRawKey(): string {
return `sk_${randomUUID().replace(/-/g, '')}`;
}
export interface ApiKeyListItem {
id: string;
name: string;
prefix: string;
createdAt: string;
lastUsedAt: string | null;
}
export interface CreatedApiKey {
id: string;
name: string;
prefix: string;
rawKey: string; // returned once only
createdAt: string;
}
export class ApiKeyService {
async create(userId: string, name: string): Promise<CreatedApiKey> {
const rawKey = generateRawKey();
const keyHash = hashKey(rawKey);
const prefix = rawKey.slice(0, KEY_PREFIX_LENGTH);
const record = await prisma.apiKey.create({
data: { userId, name, keyHash, prefix },
});
return {
id: record.id,
name: record.name,
prefix: record.prefix,
rawKey,
createdAt: record.createdAt.toISOString(),
};
}
async list(userId: string): Promise<ApiKeyListItem[]> {
const keys = await prisma.apiKey.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
return keys.map((k) => ({
id: k.id,
name: k.name,
prefix: k.prefix,
createdAt: k.createdAt.toISOString(),
lastUsedAt: k.lastUsedAt ? k.lastUsedAt.toISOString() : null,
}));
}
async delete(id: string, userId: string): Promise<void> {
const existing = await prisma.apiKey.findFirst({ where: { id, userId } });
if (!existing) {
throw new NotFoundError('API key not found');
}
await prisma.apiKey.delete({ where: { id } });
}
/**
* Verify a raw API key string. Returns the owning user or null.
* Updates lastUsedAt on success.
*/
async verify(rawKey: string): Promise<AuthenticatedUser | null> {
const keyHash = hashKey(rawKey);
const record = await prisma.apiKey.findUnique({
where: { keyHash },
include: { user: true },
});
if (!record) return null;
// Update lastUsedAt in the background — don't await to keep latency low
prisma.apiKey
.update({ where: { id: record.id }, data: { lastUsedAt: new Date() } })
.catch(() => undefined);
return {
id: record.user.id,
username: record.user.username,
fullName: record.user.fullName,
email: record.user.email,
};
}
}

View File

@@ -31,7 +31,7 @@ export interface CreateProjectInput {
export interface UpdateProjectInput {
name?: string;
description?: string;
color?: string;
color?: string | null;
clientId?: string;
}

View File

@@ -10,6 +10,7 @@ import { TimeEntriesPage } from "./pages/TimeEntriesPage";
import { ClientsPage } from "./pages/ClientsPage";
import { ProjectsPage } from "./pages/ProjectsPage";
import { StatisticsPage } from "./pages/StatisticsPage";
import { ApiKeysPage } from "./pages/ApiKeysPage";
function App() {
return (
@@ -33,6 +34,7 @@ function App() {
<Route path="clients" element={<ClientsPage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="statistics" element={<StatisticsPage />} />
<Route path="api-keys" element={<ApiKeysPage />} />
</Route>
</Routes>
</AuthProvider>

View File

@@ -0,0 +1,18 @@
import apiClient from './client';
import type { ApiKey, CreatedApiKey, CreateApiKeyInput } from '@/types';
export const apiKeysApi = {
getAll: async (): Promise<ApiKey[]> => {
const { data } = await apiClient.get<ApiKey[]>('/api-keys');
return data;
},
create: async (input: CreateApiKeyInput): Promise<CreatedApiKey> => {
const { data } = await apiClient.post<CreatedApiKey>('/api-keys', input);
return data;
},
delete: async (id: string): Promise<void> => {
await apiClient.delete(`/api-keys/${id}`);
},
};

View File

@@ -8,6 +8,7 @@ import {
LogOut,
ChevronDown,
Settings,
Key,
} from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { useState, useRef, useEffect } from "react";
@@ -40,6 +41,7 @@ export function Navbar() {
const managementItems = [
{ to: "/clients", label: "Clients", icon: Briefcase },
{ to: "/projects", label: "Projects", icon: FolderOpen },
{ to: "/api-keys", label: "API Keys", icon: Key },
];
return (

View File

@@ -0,0 +1,34 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiKeysApi } from '@/api/apiKeys';
import type { CreateApiKeyInput } from '@/types';
export function useApiKeys() {
const queryClient = useQueryClient();
const { data: apiKeys, isLoading, error } = useQuery({
queryKey: ['apiKeys'],
queryFn: apiKeysApi.getAll,
});
const createApiKey = useMutation({
mutationFn: (input: CreateApiKeyInput) => apiKeysApi.create(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['apiKeys'] });
},
});
const deleteApiKey = useMutation({
mutationFn: (id: string) => apiKeysApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['apiKeys'] });
},
});
return {
apiKeys,
isLoading,
error,
createApiKey,
deleteApiKey,
};
}

View File

@@ -0,0 +1,243 @@
import { useState } from "react";
import { Key, Plus, Trash2, Copy, Check, AlertTriangle } from "lucide-react";
import { useApiKeys } from "@/hooks/useApiKeys";
import type { CreatedApiKey } from "@/types";
export function ApiKeysPage() {
const { apiKeys, isLoading, error, createApiKey, deleteApiKey } = useApiKeys();
const [showCreateModal, setShowCreateModal] = useState(false);
const [newKeyName, setNewKeyName] = useState("");
const [createError, setCreateError] = useState<string | null>(null);
const [createdKey, setCreatedKey] = useState<CreatedApiKey | null>(null);
const [copiedKey, setCopiedKey] = useState(false);
const [revokeConfirmId, setRevokeConfirmId] = useState<string | null>(null);
function formatDate(dateStr: string | null) {
if (!dateStr) return "Never";
return new Date(dateStr).toLocaleString();
}
async function handleCreate() {
if (!newKeyName.trim()) return;
setCreateError(null);
try {
const key = await createApiKey.mutateAsync({ name: newKeyName.trim() });
setCreatedKey(key);
setNewKeyName("");
} catch (err) {
setCreateError(err instanceof Error ? err.message : "An error occurred");
}
}
async function handleCopyKey() {
if (!createdKey) return;
await navigator.clipboard.writeText(createdKey.rawKey);
setCopiedKey(true);
setTimeout(() => setCopiedKey(false), 2000);
}
function handleCloseCreateModal() {
setShowCreateModal(false);
setCreatedKey(null);
setNewKeyName("");
setCreateError(null);
setCopiedKey(false);
}
async function handleRevoke(id: string) {
try {
await deleteApiKey.mutateAsync(id);
setRevokeConfirmId(null);
} catch (_err) {
// error rendered below the table row via deleteApiKey.error
}
}
return (
<div className="max-w-4xl mx-auto py-8 px-4">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Key className="h-6 w-6 text-gray-600" />
<h1 className="text-2xl font-bold text-gray-900">API Keys</h1>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-600 text-white text-sm font-medium rounded-lg hover:bg-primary-700 transition-colors"
>
<Plus className="h-4 w-4" />
Create API Key
</button>
</div>
<p className="text-sm text-gray-500 mb-6">
API keys allow agents and external tools to authenticate with the TimeTracker API and MCP endpoint.
The raw key is only shown once at creation time store it securely.
</p>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error instanceof Error ? error.message : "Failed to load API keys"}
</div>
)}
{deleteApiKey.isError && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{deleteApiKey.error instanceof Error
? deleteApiKey.error.message
: "Failed to revoke API key"}
</div>
)}
{isLoading ? (
<div className="text-center py-12 text-gray-400 text-sm">Loading...</div>
) : !apiKeys || apiKeys.length === 0 ? (
<div className="text-center py-12 border border-dashed border-gray-300 rounded-lg">
<Key className="h-10 w-10 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 text-sm">No API keys yet. Create one to get started.</p>
</div>
) : (
<div className="border border-gray-200 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-600">Name</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Prefix</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Created</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Last Used</th>
<th className="px-4 py-3 text-right font-medium text-gray-600">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{apiKeys.map((key) => (
<tr key={key.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{key.name}</td>
<td className="px-4 py-3">
<code className="text-xs bg-gray-100 px-2 py-1 rounded font-mono text-gray-700">
{key.prefix}
</code>
</td>
<td className="px-4 py-3 text-gray-500">{formatDate(key.createdAt)}</td>
<td className="px-4 py-3 text-gray-500">{formatDate(key.lastUsedAt)}</td>
<td className="px-4 py-3 text-right">
{revokeConfirmId === key.id ? (
<div className="inline-flex items-center gap-2">
<span className="text-xs text-red-600">Revoke?</span>
<button
onClick={() => handleRevoke(key.id)}
disabled={deleteApiKey.isPending}
className="text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50"
>
Yes
</button>
<button
onClick={() => setRevokeConfirmId(null)}
className="text-xs px-2 py-1 border border-gray-300 rounded hover:bg-gray-50 transition-colors"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setRevokeConfirmId(key.id)}
className="inline-flex items-center gap-1 px-2 py-1 text-red-600 hover:text-red-800 hover:bg-red-50 rounded transition-colors"
title="Revoke key"
>
<Trash2 className="h-4 w-4" />
<span>Revoke</span>
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Create API Key Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-md">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Create API Key</h2>
</div>
<div className="px-6 py-5">
{createdKey ? (
/* One-time key reveal */
<div className="space-y-4">
<div className="flex items-start gap-3 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<AlertTriangle className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-800">
Copy this key now. <strong>It will not be shown again.</strong>
</p>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Your new API key</label>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-gray-100 border border-gray-200 rounded-lg px-3 py-2 font-mono text-gray-900 break-all">
{createdKey.rawKey}
</code>
<button
onClick={handleCopyKey}
className="flex-shrink-0 p-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
title="Copy to clipboard"
>
{copiedKey ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4 text-gray-500" />
)}
</button>
</div>
</div>
</div>
) : (
/* Name input form */
<div className="space-y-4">
<div>
<label htmlFor="key-name" className="block text-sm font-medium text-gray-700 mb-1">
Key name
</label>
<input
id="key-name"
type="text"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
placeholder="e.g. My Claude Agent"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
autoFocus
/>
</div>
{createError && (
<p className="text-red-600 text-sm">{createError}</p>
)}
</div>
)}
</div>
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
<button
onClick={handleCloseCreateModal}
className="px-4 py-2 text-sm font-medium border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
{createdKey ? "Done" : "Cancel"}
</button>
{!createdKey && (
<button
onClick={handleCreate}
disabled={!newKeyName.trim() || createApiKey.isPending}
className="px-4 py-2 text-sm font-medium bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{createApiKey.isPending ? "Creating..." : "Create"}
</button>
)}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -210,3 +210,19 @@ export interface CreateCorrectionInput {
hours: number;
description?: string;
}
export interface ApiKey {
id: string;
name: string;
prefix: string;
createdAt: string;
lastUsedAt: string | null;
}
export interface CreatedApiKey extends ApiKey {
rawKey: string; // returned only on creation
}
export interface CreateApiKeyInput {
name: string;
}