@trainheroic-unofficial/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +59 -0
- package/dist/index.d.mts +90 -0
- package/dist/index.mjs +584 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Alan Cohen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# @trainheroic-unofficial/core
|
|
2
|
+
|
|
3
|
+
The shared MCP tool layer for TrainHeroic. Each tool is defined once here and reused by both
|
|
4
|
+
servers: the local stdio server (`@trainheroic-unofficial/coach-mcp`) and the hosted
|
|
5
|
+
Cloudflare worker (`@trainheroic-unofficial/cloudflare`). Keeping the tools here is what
|
|
6
|
+
makes the two transports behave identically.
|
|
7
|
+
|
|
8
|
+
Part of the [trainheroic-unofficial](../../README.md) workspace.
|
|
9
|
+
|
|
10
|
+
## How a server uses it
|
|
11
|
+
|
|
12
|
+
A server builds a `ToolContext` (an authenticated `TrainHeroicClient` plus something that
|
|
13
|
+
implements the `ExerciseIndex` interface) and calls the `registerXxxTools(server, ctx)`
|
|
14
|
+
functions:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import {
|
|
18
|
+
registerReadTools,
|
|
19
|
+
registerRawTools,
|
|
20
|
+
registerExerciseTools,
|
|
21
|
+
registerWorkoutTools,
|
|
22
|
+
registerMessagingTools,
|
|
23
|
+
type ToolContext,
|
|
24
|
+
} from "@trainheroic-unofficial/core";
|
|
25
|
+
|
|
26
|
+
const ctx: ToolContext = { client, index };
|
|
27
|
+
registerReadTools(server, ctx);
|
|
28
|
+
registerExerciseTools(server, ctx);
|
|
29
|
+
// ...and the rest
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Because the context depends on the `ExerciseIndex` interface rather than a concrete store,
|
|
33
|
+
the same tools run against the local in-memory library and the hosted D1 mirror without
|
|
34
|
+
change.
|
|
35
|
+
|
|
36
|
+
## What the tools cover
|
|
37
|
+
|
|
38
|
+
The tools group into coach reads (profile, athletes, teams, programs, notifications,
|
|
39
|
+
analytics), exercise library operations (resolve, search, get, sync, create, forget,
|
|
40
|
+
stats), the workout lifecycle (build a draft, read it back, publish, remove), messaging
|
|
41
|
+
(list, read, draft, send, delete), and a `th_request` escape hatch for any endpoint the
|
|
42
|
+
dedicated tools do not cover.
|
|
43
|
+
|
|
44
|
+
Tools return their result in-band. A failure comes back as an error result the model can
|
|
45
|
+
read and correct, not a thrown exception. Reads are marked read-only. Athlete-facing or
|
|
46
|
+
destructive actions (publish, remove, send, delete, and non-GET `th_request`) pass through a
|
|
47
|
+
confirmation gate before they run.
|
|
48
|
+
|
|
49
|
+
The D1-backed warehouse sync tools are not here; they live in the `cloudflare` package
|
|
50
|
+
because they depend on its storage.
|
|
51
|
+
|
|
52
|
+
## Develop
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pnpm build # tsdown
|
|
56
|
+
pnpm typecheck
|
|
57
|
+
pnpm test
|
|
58
|
+
pnpm exec vitest run test/confirm.test.ts # one file
|
|
59
|
+
```
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { ExerciseIndex, RequestOptions, TrainHeroicClient } from "@trainheroic-unofficial/js";
|
|
2
|
+
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
|
|
5
|
+
//#region src/context.d.ts
|
|
6
|
+
/** A tool argument that accepts a numeric id as a number or a string of digits. */
|
|
7
|
+
declare const idParam: import("zod").ZodUnion<readonly [import("zod").ZodNumber, import("zod").ZodString]>;
|
|
8
|
+
declare function toId(value: string | number): number;
|
|
9
|
+
declare const READ: {
|
|
10
|
+
readonly readOnlyHint: true;
|
|
11
|
+
readonly openWorldHint: true;
|
|
12
|
+
};
|
|
13
|
+
declare const SYNC: {
|
|
14
|
+
readonly readOnlyHint: false;
|
|
15
|
+
readonly idempotentHint: true;
|
|
16
|
+
readonly destructiveHint: false;
|
|
17
|
+
readonly openWorldHint: true;
|
|
18
|
+
};
|
|
19
|
+
declare const DESTRUCTIVE: {
|
|
20
|
+
readonly readOnlyHint: false;
|
|
21
|
+
readonly destructiveHint: true;
|
|
22
|
+
readonly openWorldHint: true;
|
|
23
|
+
};
|
|
24
|
+
/** Everything a tool handler needs: the authenticated client and the exercise index. */
|
|
25
|
+
type ToolContext = {
|
|
26
|
+
client: TrainHeroicClient;
|
|
27
|
+
index: ExerciseIndex;
|
|
28
|
+
};
|
|
29
|
+
/** Run a tool body, converting thrown errors into an in-band tool error. */
|
|
30
|
+
declare function attempt(fn: () => Promise<CallToolResult>): Promise<CallToolResult>;
|
|
31
|
+
/** Conservative per-result character cap, below the smallest host cap. */
|
|
32
|
+
declare const DEFAULT_RESULT_BUDGET = 60000;
|
|
33
|
+
/** Active budget. Overridable via TH_MCP_RESULT_BUDGET on Node; the default on workerd. */
|
|
34
|
+
declare function resultBudget(): number;
|
|
35
|
+
/**
|
|
36
|
+
* Serialize `data` as JSON within `budget` characters. Small results are pretty-printed.
|
|
37
|
+
* Oversized results degrade in order: trim a top-level array (wrapping it as
|
|
38
|
+
* `{ items, __truncated }`), then a top-level object's largest array property (annotated
|
|
39
|
+
* with `__truncated`), then a last-resort hard character cap. Pure and side-effect free.
|
|
40
|
+
*/
|
|
41
|
+
declare function boundedSerialize(data: unknown, budget: number, hint?: string): string;
|
|
42
|
+
/** Per-tool guidance threaded into the truncation marker when a result is too large. */
|
|
43
|
+
type BudgetHint = {
|
|
44
|
+
hint?: string | undefined;
|
|
45
|
+
};
|
|
46
|
+
/** A successful tool result carrying JSON (or text) for the model, size-bounded. */
|
|
47
|
+
declare function jsonResult(data: unknown, opts?: BudgetHint): CallToolResult;
|
|
48
|
+
/** A tool-level error: returned in-band (isError) so the model can self-correct. */
|
|
49
|
+
declare function errorResult(message: string): CallToolResult;
|
|
50
|
+
/** Issue a TrainHeroic request and format the outcome as a tool result. */
|
|
51
|
+
declare function apiCall(ctx: ToolContext, method: string, path: string, options?: RequestOptions, hint?: string): Promise<CallToolResult>;
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region src/confirm.d.ts
|
|
54
|
+
declare const NOT_CONFIRMED = "Not confirmed. Re-run with confirm:true, or connect a client that supports MCP elicitation.";
|
|
55
|
+
/**
|
|
56
|
+
* Confirm a destructive/athlete-facing action. Prefers MCP elicitation (an
|
|
57
|
+
* in-the-moment user prompt); falls back to an explicit confirm:true argument when
|
|
58
|
+
* the client does not support elicitation. Never proceeds without one of the two.
|
|
59
|
+
*/
|
|
60
|
+
declare function confirmGate(server: McpServer, requestId: string | number | undefined, message: string, confirmArg: boolean | undefined): Promise<boolean>;
|
|
61
|
+
//#endregion
|
|
62
|
+
//#region src/tools/reads.d.ts
|
|
63
|
+
/** Read-only coach/athlete queries. Exercise lookups live in the exercise store tools. */
|
|
64
|
+
declare function registerReadTools(server: McpServer, ctx: ToolContext): void;
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/tools/raw.d.ts
|
|
67
|
+
/**
|
|
68
|
+
* Escape hatch covering every endpoint without a dedicated tool (e.g. the analytics
|
|
69
|
+
* POSTs). GET is ungated; mutating methods go through the same confirmation gate as
|
|
70
|
+
* the dedicated destructive tools so this cannot be used to bypass it.
|
|
71
|
+
*/
|
|
72
|
+
declare function registerRawTools(server: McpServer, ctx: ToolContext): void;
|
|
73
|
+
//#endregion
|
|
74
|
+
//#region src/tools/exercises.d.ts
|
|
75
|
+
/**
|
|
76
|
+
* Exercise library tools over the ExerciseIndex — a D1 mirror on the hosted server, an
|
|
77
|
+
* on-disk/in-memory cache locally. The descriptions stay at the interface level so they
|
|
78
|
+
* read correctly on both backends.
|
|
79
|
+
*/
|
|
80
|
+
declare function registerExerciseTools(server: McpServer, ctx: ToolContext): void;
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region src/tools/workout.d.ts
|
|
83
|
+
/** Workout building, read-back, publishing, and removal. */
|
|
84
|
+
declare function registerWorkoutTools(server: McpServer, ctx: ToolContext): void;
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/tools/messaging.d.ts
|
|
87
|
+
/** Live messaging: list/read conversations, draft a message, and the gated send/delete. */
|
|
88
|
+
declare function registerMessagingTools(server: McpServer, ctx: ToolContext): void;
|
|
89
|
+
//#endregion
|
|
90
|
+
export { BudgetHint, DEFAULT_RESULT_BUDGET, DESTRUCTIVE, NOT_CONFIRMED, READ, SYNC, ToolContext, apiCall, attempt, boundedSerialize, confirmGate, errorResult, idParam, jsonResult, registerExerciseTools, registerMessagingTools, registerRawTools, registerReadTools, registerWorkoutTools, resultBudget, toId };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
import { blockSpecSchema, commentDraftSchema, exerciseCreateSchema, idArgSchema, parseWorkoutDate } from "@trainheroic-unofficial/dto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { buildCommentPayload, buildSession, collectAdvisories, deleteComment, fetchStreams, publishSession, readLive, readSession, removeSession, sendComment } from "@trainheroic-unofficial/js";
|
|
4
|
+
//#region src/context.ts
|
|
5
|
+
/** A tool argument that accepts a numeric id as a number or a string of digits. */
|
|
6
|
+
const idParam = idArgSchema;
|
|
7
|
+
function toId(value) {
|
|
8
|
+
return typeof value === "number" ? value : Number(value);
|
|
9
|
+
}
|
|
10
|
+
const READ = {
|
|
11
|
+
readOnlyHint: true,
|
|
12
|
+
openWorldHint: true
|
|
13
|
+
};
|
|
14
|
+
const SYNC = {
|
|
15
|
+
readOnlyHint: false,
|
|
16
|
+
idempotentHint: true,
|
|
17
|
+
destructiveHint: false,
|
|
18
|
+
openWorldHint: true
|
|
19
|
+
};
|
|
20
|
+
const DESTRUCTIVE = {
|
|
21
|
+
readOnlyHint: false,
|
|
22
|
+
destructiveHint: true,
|
|
23
|
+
openWorldHint: true
|
|
24
|
+
};
|
|
25
|
+
/** Run a tool body, converting thrown errors into an in-band tool error. */
|
|
26
|
+
async function attempt(fn) {
|
|
27
|
+
try {
|
|
28
|
+
return await fn();
|
|
29
|
+
} catch (err) {
|
|
30
|
+
return errorResult(err instanceof Error ? err.message : String(err));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Conservative per-result character cap, below the smallest host cap. */
|
|
34
|
+
const DEFAULT_RESULT_BUDGET = 6e4;
|
|
35
|
+
/** Reserve for the `__truncated` marker so wrapping cannot push back over budget. */
|
|
36
|
+
const MARKER_RESERVE = 300;
|
|
37
|
+
const DEFAULT_ARRAY_HINT = "Result was truncated to fit the size budget. Narrow it with a filter/search argument or paginate to see the rest.";
|
|
38
|
+
const DEFAULT_OBJECT_HINT = "Result was truncated to fit the size budget. Request a more specific id or sub-resource.";
|
|
39
|
+
/** Active budget. Overridable via TH_MCP_RESULT_BUDGET on Node; the default on workerd. */
|
|
40
|
+
function resultBudget() {
|
|
41
|
+
const raw = (globalThis.process?.env)?.TH_MCP_RESULT_BUDGET;
|
|
42
|
+
const n = raw ? Number(raw) : NaN;
|
|
43
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_RESULT_BUDGET;
|
|
44
|
+
}
|
|
45
|
+
function isPlainObject(value) {
|
|
46
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
47
|
+
}
|
|
48
|
+
/** Largest count k such that the JSON of the first k pre-serialized pieces fits. O(n). */
|
|
49
|
+
function largestPrefixCount(pieces, charBudget) {
|
|
50
|
+
let used = 2;
|
|
51
|
+
let k = 0;
|
|
52
|
+
for (const piece of pieces) {
|
|
53
|
+
const add = piece.length + (k > 0 ? 1 : 0);
|
|
54
|
+
if (used + add > charBudget) break;
|
|
55
|
+
used += add;
|
|
56
|
+
k += 1;
|
|
57
|
+
}
|
|
58
|
+
return k;
|
|
59
|
+
}
|
|
60
|
+
/** Last resort: cap a string at the budget and label it as truncated, non-JSON output. */
|
|
61
|
+
function hardCap(text, budget, hint) {
|
|
62
|
+
if (text.length <= budget) return text;
|
|
63
|
+
const note = `\n\n[TRUNCATED: output exceeded ${budget} chars and is NOT valid JSON. ${hint ?? "Narrow the query (filter, paginate, or fetch a specific id)."}]`;
|
|
64
|
+
const keep = Math.max(0, budget - note.length);
|
|
65
|
+
return text.slice(0, keep) + note;
|
|
66
|
+
}
|
|
67
|
+
function largestArrayValuedKey(obj) {
|
|
68
|
+
let best = null;
|
|
69
|
+
let bestLen = -1;
|
|
70
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
71
|
+
if (!Array.isArray(value)) continue;
|
|
72
|
+
const len = (JSON.stringify(value) ?? "[]").length;
|
|
73
|
+
if (len > bestLen) {
|
|
74
|
+
best = key;
|
|
75
|
+
bestLen = len;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return best;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Serialize `data` as JSON within `budget` characters. Small results are pretty-printed.
|
|
82
|
+
* Oversized results degrade in order: trim a top-level array (wrapping it as
|
|
83
|
+
* `{ items, __truncated }`), then a top-level object's largest array property (annotated
|
|
84
|
+
* with `__truncated`), then a last-resort hard character cap. Pure and side-effect free.
|
|
85
|
+
*/
|
|
86
|
+
function boundedSerialize(data, budget, hint) {
|
|
87
|
+
if (typeof data === "string") return hardCap(data, budget, hint);
|
|
88
|
+
const compact = JSON.stringify(data) ?? "null";
|
|
89
|
+
if (compact.length <= budget) {
|
|
90
|
+
const pretty = JSON.stringify(data, null, 2) ?? "null";
|
|
91
|
+
return pretty.length <= budget ? pretty : compact;
|
|
92
|
+
}
|
|
93
|
+
if (Array.isArray(data)) {
|
|
94
|
+
const k = largestPrefixCount(data.map((el) => JSON.stringify(el) ?? "null"), budget - MARKER_RESERVE);
|
|
95
|
+
const wrapped = {
|
|
96
|
+
items: data.slice(0, k),
|
|
97
|
+
__truncated: {
|
|
98
|
+
returned: k,
|
|
99
|
+
total: data.length,
|
|
100
|
+
omitted: data.length - k,
|
|
101
|
+
hint: hint ?? DEFAULT_ARRAY_HINT
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const out = JSON.stringify(wrapped);
|
|
105
|
+
if (out.length <= budget) return out;
|
|
106
|
+
} else if (isPlainObject(data)) {
|
|
107
|
+
const key = largestArrayValuedKey(data);
|
|
108
|
+
if (key !== null) {
|
|
109
|
+
const arr = data[key];
|
|
110
|
+
const pieces = arr.map((el) => JSON.stringify(el) ?? "null");
|
|
111
|
+
const restLen = (JSON.stringify({
|
|
112
|
+
...data,
|
|
113
|
+
[key]: []
|
|
114
|
+
}) ?? "{}").length;
|
|
115
|
+
const k = largestPrefixCount(pieces, Math.max(0, budget - MARKER_RESERVE - restLen));
|
|
116
|
+
const clone = {
|
|
117
|
+
...data,
|
|
118
|
+
[key]: arr.slice(0, k),
|
|
119
|
+
__truncated: {
|
|
120
|
+
field: key,
|
|
121
|
+
returned: k,
|
|
122
|
+
total: arr.length,
|
|
123
|
+
omitted: arr.length - k,
|
|
124
|
+
hint: hint ?? DEFAULT_OBJECT_HINT
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
const out = JSON.stringify(clone);
|
|
128
|
+
if (out.length <= budget) return out;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return hardCap(compact, budget, hint);
|
|
132
|
+
}
|
|
133
|
+
/** A successful tool result carrying JSON (or text) for the model, size-bounded. */
|
|
134
|
+
function jsonResult(data, opts) {
|
|
135
|
+
return { content: [{
|
|
136
|
+
type: "text",
|
|
137
|
+
text: boundedSerialize(data, resultBudget(), opts?.hint)
|
|
138
|
+
}] };
|
|
139
|
+
}
|
|
140
|
+
/** A tool-level error: returned in-band (isError) so the model can self-correct. */
|
|
141
|
+
function errorResult(message) {
|
|
142
|
+
return {
|
|
143
|
+
isError: true,
|
|
144
|
+
content: [{
|
|
145
|
+
type: "text",
|
|
146
|
+
text: message
|
|
147
|
+
}]
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/** Issue a TrainHeroic request and format the outcome as a tool result. */
|
|
151
|
+
async function apiCall(ctx, method, path, options, hint) {
|
|
152
|
+
return attempt(async () => {
|
|
153
|
+
const res = await ctx.client.request(method, path, options);
|
|
154
|
+
if (!res.ok) {
|
|
155
|
+
const detail = hardCap(typeof res.data === "string" ? res.data : JSON.stringify(res.data) ?? "", resultBudget());
|
|
156
|
+
return errorResult(`TrainHeroic API error (HTTP ${res.status}): ${detail}`);
|
|
157
|
+
}
|
|
158
|
+
return jsonResult(res.data, { hint });
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
//#endregion
|
|
162
|
+
//#region src/confirm.ts
|
|
163
|
+
const NOT_CONFIRMED = "Not confirmed. Re-run with confirm:true, or connect a client that supports MCP elicitation.";
|
|
164
|
+
/**
|
|
165
|
+
* Confirm a destructive/athlete-facing action. Prefers MCP elicitation (an
|
|
166
|
+
* in-the-moment user prompt); falls back to an explicit confirm:true argument when
|
|
167
|
+
* the client does not support elicitation. Never proceeds without one of the two.
|
|
168
|
+
*/
|
|
169
|
+
async function confirmGate(server, requestId, message, confirmArg) {
|
|
170
|
+
if (confirmArg === true) return true;
|
|
171
|
+
try {
|
|
172
|
+
const result = await server.server.elicitInput({
|
|
173
|
+
message,
|
|
174
|
+
requestedSchema: {
|
|
175
|
+
type: "object",
|
|
176
|
+
properties: { confirm: {
|
|
177
|
+
type: "boolean",
|
|
178
|
+
title: "Confirm",
|
|
179
|
+
description: message
|
|
180
|
+
} },
|
|
181
|
+
required: ["confirm"]
|
|
182
|
+
}
|
|
183
|
+
}, requestId === void 0 ? void 0 : { relatedRequestId: requestId });
|
|
184
|
+
return result.action === "accept" && result.content?.confirm === true;
|
|
185
|
+
} catch (err) {
|
|
186
|
+
console.warn("MCP elicitation unavailable; treating as not confirmed", err);
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
//#endregion
|
|
191
|
+
//#region src/tools/reads.ts
|
|
192
|
+
function enc(value) {
|
|
193
|
+
return encodeURIComponent(String(value));
|
|
194
|
+
}
|
|
195
|
+
/** No-argument GET endpoints, registered from a table to keep the wiring compact. */
|
|
196
|
+
const SIMPLE_GETS = [
|
|
197
|
+
{
|
|
198
|
+
name: "whoami",
|
|
199
|
+
title: "Who am I",
|
|
200
|
+
description: "The authenticated TrainHeroic coach profile (id, org_id, name, roles, trial days).",
|
|
201
|
+
path: "/user/simple"
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
name: "head_coach",
|
|
205
|
+
title: "Head coach / org",
|
|
206
|
+
description: "Org, license, and trial status for the head coach account.",
|
|
207
|
+
path: "/v5/headCoach"
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
name: "list_programs",
|
|
211
|
+
title: "List programs",
|
|
212
|
+
description: "Coach programs (standalone). Team group-programs come from list_teams.",
|
|
213
|
+
path: "/1.0/coach/programs"
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
name: "notifications",
|
|
217
|
+
title: "Notification counts",
|
|
218
|
+
description: "Unread counts including countMessagingNotViewed (cheap 'anything new?' poll).",
|
|
219
|
+
path: "/v5/notifications/counts"
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
name: "analytics_categories",
|
|
223
|
+
title: "Analytics categories",
|
|
224
|
+
description: "Lists available analytics types. Pull the data via th_request POST /v5/analytics/*.",
|
|
225
|
+
path: "/v5/analytics"
|
|
226
|
+
}
|
|
227
|
+
];
|
|
228
|
+
/** Read-only coach/athlete queries. Exercise lookups live in the exercise store tools. */
|
|
229
|
+
function registerReadTools(server, ctx) {
|
|
230
|
+
for (const t of SIMPLE_GETS) server.registerTool(t.name, {
|
|
231
|
+
title: t.title,
|
|
232
|
+
description: t.description,
|
|
233
|
+
inputSchema: {},
|
|
234
|
+
annotations: READ
|
|
235
|
+
}, () => apiCall(ctx, "GET", t.path));
|
|
236
|
+
server.registerTool("list_athletes", {
|
|
237
|
+
title: "List athletes",
|
|
238
|
+
description: "Athletes visible to this coach. Optional q (case-insensitive substring filter over each athlete record) and limit, applied client-side, to keep large rosters small.",
|
|
239
|
+
inputSchema: {
|
|
240
|
+
q: z.string().optional(),
|
|
241
|
+
limit: z.number().int().positive().max(500).optional()
|
|
242
|
+
},
|
|
243
|
+
annotations: READ
|
|
244
|
+
}, ({ q, limit }) => attempt(async () => {
|
|
245
|
+
const res = await ctx.client.request("GET", "/v5/athletes");
|
|
246
|
+
if (!res.ok) {
|
|
247
|
+
const detail = typeof res.data === "string" ? res.data : JSON.stringify(res.data);
|
|
248
|
+
throw new Error(`TrainHeroic API error (HTTP ${res.status}): ${detail}`);
|
|
249
|
+
}
|
|
250
|
+
if (!Array.isArray(res.data)) return jsonResult(res.data);
|
|
251
|
+
let rows = res.data;
|
|
252
|
+
if (q !== void 0) {
|
|
253
|
+
const needle = q.toLowerCase();
|
|
254
|
+
rows = rows.filter((row) => JSON.stringify(row).toLowerCase().includes(needle));
|
|
255
|
+
}
|
|
256
|
+
const total = rows.length;
|
|
257
|
+
if (limit !== void 0 && rows.length > limit) return jsonResult({
|
|
258
|
+
items: rows.slice(0, limit),
|
|
259
|
+
returned: limit,
|
|
260
|
+
total,
|
|
261
|
+
note: "Limited client-side. Raise limit or narrow with q for the rest."
|
|
262
|
+
});
|
|
263
|
+
return jsonResult(rows, { hint: "Filter with q (name/email substring) or cap with limit to shrink this list." });
|
|
264
|
+
}));
|
|
265
|
+
server.registerTool("list_teams", {
|
|
266
|
+
title: "List teams",
|
|
267
|
+
description: "Coach teams. Optional pagination and search.",
|
|
268
|
+
inputSchema: {
|
|
269
|
+
page: z.number().int().positive().optional(),
|
|
270
|
+
pageSize: z.number().int().positive().optional(),
|
|
271
|
+
q: z.string().optional()
|
|
272
|
+
},
|
|
273
|
+
annotations: READ
|
|
274
|
+
}, ({ page, pageSize, q }) => {
|
|
275
|
+
const qs = new URLSearchParams();
|
|
276
|
+
if (page !== void 0) qs.set("page", String(page));
|
|
277
|
+
if (pageSize !== void 0) qs.set("pageSize", String(pageSize));
|
|
278
|
+
if (q !== void 0) qs.set("q", q);
|
|
279
|
+
const query = qs.toString();
|
|
280
|
+
return apiCall(ctx, "GET", `/1.0/coach/teams${query ? `?${query}` : ""}`);
|
|
281
|
+
});
|
|
282
|
+
server.registerTool("get_team", {
|
|
283
|
+
title: "Get team",
|
|
284
|
+
description: "Full team object by team id.",
|
|
285
|
+
inputSchema: { teamId: idParam },
|
|
286
|
+
annotations: READ
|
|
287
|
+
}, ({ teamId }) => apiCall(ctx, "GET", `/v5/teams/${enc(teamId)}`));
|
|
288
|
+
server.registerTool("list_team_codes", {
|
|
289
|
+
title: "List team access codes",
|
|
290
|
+
description: "Join/access codes for a team.",
|
|
291
|
+
inputSchema: { teamId: idParam },
|
|
292
|
+
annotations: READ
|
|
293
|
+
}, ({ teamId }) => apiCall(ctx, "GET", `/v5/teams/${enc(teamId)}/teamCodes`));
|
|
294
|
+
server.registerTool("get_program", {
|
|
295
|
+
title: "Get program detail",
|
|
296
|
+
description: "Full nested program structure (blocks + sessions) live from the API, by program id.",
|
|
297
|
+
inputSchema: { programId: idParam },
|
|
298
|
+
annotations: READ
|
|
299
|
+
}, ({ programId }) => apiCall(ctx, "GET", `/3.0/coach/program/${enc(programId)}`, void 0, "This is a large, deep object. If it is truncated, fetch a narrower view (a specific session) instead."));
|
|
300
|
+
}
|
|
301
|
+
//#endregion
|
|
302
|
+
//#region src/tools/raw.ts
|
|
303
|
+
/**
|
|
304
|
+
* Escape hatch covering every endpoint without a dedicated tool (e.g. the analytics
|
|
305
|
+
* POSTs). GET is ungated; mutating methods go through the same confirmation gate as
|
|
306
|
+
* the dedicated destructive tools so this cannot be used to bypass it.
|
|
307
|
+
*/
|
|
308
|
+
function registerRawTools(server, ctx) {
|
|
309
|
+
server.registerTool("th_request", {
|
|
310
|
+
title: "Raw TrainHeroic request",
|
|
311
|
+
description: "Call any TrainHeroic endpoint directly. `path` is everything after the host. `base` selects the host: 'coach' = api.trainheroic.com (default), 'apis' = apis.trainheroic.com. Prefer dedicated tools where they exist. POST/PUT/DELETE act on the live account and require confirmation.",
|
|
312
|
+
inputSchema: {
|
|
313
|
+
method: z.enum([
|
|
314
|
+
"GET",
|
|
315
|
+
"POST",
|
|
316
|
+
"PUT",
|
|
317
|
+
"DELETE"
|
|
318
|
+
]),
|
|
319
|
+
path: z.string().min(1),
|
|
320
|
+
body: z.unknown().optional(),
|
|
321
|
+
base: z.enum(["coach", "apis"]).optional(),
|
|
322
|
+
confirm: z.boolean().optional()
|
|
323
|
+
},
|
|
324
|
+
annotations: DESTRUCTIVE
|
|
325
|
+
}, async ({ method, path, body, base, confirm }, extra) => {
|
|
326
|
+
if (method !== "GET") {
|
|
327
|
+
if (!await confirmGate(server, extra.requestId, `Run ${method} ${path} against the live TrainHeroic account?`, confirm)) return errorResult(NOT_CONFIRMED);
|
|
328
|
+
}
|
|
329
|
+
const options = {};
|
|
330
|
+
if (body !== void 0) options.body = body;
|
|
331
|
+
if (base !== void 0) options.base = base;
|
|
332
|
+
return apiCall(ctx, method, path, options, "Large or unfiltered response. Add query params to narrow it, or use a dedicated tool for this endpoint if one exists.");
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
//#endregion
|
|
336
|
+
//#region src/tools/exercises.ts
|
|
337
|
+
/**
|
|
338
|
+
* Exercise library tools over the ExerciseIndex — a D1 mirror on the hosted server, an
|
|
339
|
+
* on-disk/in-memory cache locally. The descriptions stay at the interface level so they
|
|
340
|
+
* read correctly on both backends.
|
|
341
|
+
*/
|
|
342
|
+
function registerExerciseTools(server, ctx) {
|
|
343
|
+
const index = ctx.index;
|
|
344
|
+
server.registerTool("exercise_resolve", {
|
|
345
|
+
title: "Resolve exercise name",
|
|
346
|
+
description: "Map a name to an exercise id via the local mirror. Returns the match plus ranked candidates; when ambiguous, match is null and you should pick from candidates. Units (param_1_unit/param_2_unit) are fixed per exercise — check them before prescribing.",
|
|
347
|
+
inputSchema: { name: z.string().min(1) },
|
|
348
|
+
annotations: READ
|
|
349
|
+
}, ({ name }) => attempt(async () => jsonResult(await index.resolve(name))));
|
|
350
|
+
server.registerTool("exercise_search", {
|
|
351
|
+
title: "Search exercises",
|
|
352
|
+
description: "Ranked fuzzy search over exercise titles. Returns candidates with units.",
|
|
353
|
+
inputSchema: {
|
|
354
|
+
query: z.string().min(1),
|
|
355
|
+
limit: z.number().int().positive().max(100).optional()
|
|
356
|
+
},
|
|
357
|
+
annotations: READ
|
|
358
|
+
}, ({ query, limit }) => attempt(async () => jsonResult(await index.search(query, limit ?? 20))));
|
|
359
|
+
server.registerTool("exercise_get", {
|
|
360
|
+
title: "Get exercise",
|
|
361
|
+
description: "Full exercise object (with units) by id.",
|
|
362
|
+
inputSchema: { id: idParam },
|
|
363
|
+
annotations: READ
|
|
364
|
+
}, ({ id }) => attempt(async () => {
|
|
365
|
+
const ex = await index.get(toId(id));
|
|
366
|
+
return ex ? jsonResult(ex) : errorResult(`No exercise with id ${toId(id)}.`);
|
|
367
|
+
}));
|
|
368
|
+
server.registerTool("exercise_sync", {
|
|
369
|
+
title: "Sync exercise library",
|
|
370
|
+
description: "Refresh the cached exercise index from TrainHeroic.",
|
|
371
|
+
inputSchema: { force: z.boolean().optional() },
|
|
372
|
+
annotations: SYNC
|
|
373
|
+
}, ({ force }) => attempt(async () => {
|
|
374
|
+
if (force ?? false) return jsonResult(await index.refresh());
|
|
375
|
+
await index.ensureFresh();
|
|
376
|
+
return jsonResult(await index.stats());
|
|
377
|
+
}));
|
|
378
|
+
server.registerTool("exercise_create", {
|
|
379
|
+
title: "Create custom exercise",
|
|
380
|
+
description: "Create a custom exercise (POST /2.0/coach/exercise/create) and write it through to the mirror. Body example: {\"title\":\"Sandbag Clean\",\"param_1_type\":3,\"param_2_type\":1}.",
|
|
381
|
+
inputSchema: { exercise: exerciseCreateSchema },
|
|
382
|
+
annotations: {
|
|
383
|
+
readOnlyHint: false,
|
|
384
|
+
destructiveHint: false,
|
|
385
|
+
openWorldHint: true
|
|
386
|
+
}
|
|
387
|
+
}, ({ exercise }) => attempt(async () => jsonResult(await index.create(exercise))));
|
|
388
|
+
server.registerTool("exercise_forget", {
|
|
389
|
+
title: "Forget exercise (cache only)",
|
|
390
|
+
description: "Remove an exercise from the local mirror only. Run after deleting it via the API.",
|
|
391
|
+
inputSchema: { id: idParam },
|
|
392
|
+
annotations: {
|
|
393
|
+
readOnlyHint: false,
|
|
394
|
+
idempotentHint: true,
|
|
395
|
+
openWorldHint: false
|
|
396
|
+
}
|
|
397
|
+
}, ({ id }) => attempt(async () => {
|
|
398
|
+
await index.recordDelete(toId(id));
|
|
399
|
+
return jsonResult({ forgotten: toId(id) });
|
|
400
|
+
}));
|
|
401
|
+
server.registerTool("store_stats", {
|
|
402
|
+
title: "Exercise index stats",
|
|
403
|
+
description: "Row counts and sync state for the cached exercise index.",
|
|
404
|
+
inputSchema: {},
|
|
405
|
+
annotations: READ
|
|
406
|
+
}, () => attempt(async () => jsonResult(await index.stats())));
|
|
407
|
+
}
|
|
408
|
+
//#endregion
|
|
409
|
+
//#region src/tools/workout.ts
|
|
410
|
+
/** Workout building, read-back, publishing, and removal. */
|
|
411
|
+
function registerWorkoutTools(server, ctx) {
|
|
412
|
+
server.registerTool("workout_build", {
|
|
413
|
+
title: "Build a workout session (draft)",
|
|
414
|
+
description: "Build an UNPUBLISHED session from a spec (program -> session -> blocks -> exercises). Two exercises in one block become a superset. Add a block 'leaderboard' for a Red-Zone score, or a top-level 'instruction' for the session note (Coach Instructions). Returns the draft ids, a read-back, and unit advisories. Review, then workout_publish.",
|
|
415
|
+
inputSchema: {
|
|
416
|
+
programId: z.number(),
|
|
417
|
+
date: z.string().optional(),
|
|
418
|
+
timelineDay: z.number().optional(),
|
|
419
|
+
blocks: z.array(blockSpecSchema),
|
|
420
|
+
instruction: z.string().optional()
|
|
421
|
+
},
|
|
422
|
+
annotations: {
|
|
423
|
+
readOnlyHint: false,
|
|
424
|
+
destructiveHint: false,
|
|
425
|
+
openWorldHint: true
|
|
426
|
+
}
|
|
427
|
+
}, ({ programId, date, timelineDay, blocks, instruction }) => attempt(async () => {
|
|
428
|
+
if (date === void 0 && timelineDay === void 0) return errorResult("Provide either date (YYYY-M-D) or timelineDay.");
|
|
429
|
+
const typed = blocks;
|
|
430
|
+
const opts = {
|
|
431
|
+
programId,
|
|
432
|
+
blocks: typed,
|
|
433
|
+
publish: false
|
|
434
|
+
};
|
|
435
|
+
if (date !== void 0) opts.date = parseWorkoutDate(date);
|
|
436
|
+
if (timelineDay !== void 0) opts.timelineDay = timelineDay;
|
|
437
|
+
if (instruction !== void 0) opts.instruction = instruction;
|
|
438
|
+
const advisories = await collectAdvisories(typed, ctx.index);
|
|
439
|
+
const built = await buildSession(ctx.client, opts);
|
|
440
|
+
const readback = opts.date ? await readSession(ctx.client, programId, opts.date, built.pwId) : null;
|
|
441
|
+
return jsonResult({
|
|
442
|
+
...built,
|
|
443
|
+
published: false,
|
|
444
|
+
advisories,
|
|
445
|
+
readback,
|
|
446
|
+
note: "Draft created (unpublished). Review, then call workout_publish to make it athlete-facing."
|
|
447
|
+
});
|
|
448
|
+
}));
|
|
449
|
+
server.registerTool("workout_read", {
|
|
450
|
+
title: "Read a built session",
|
|
451
|
+
description: "Read-back a session by programId, date (YYYY-M-D), and programWorkout id.",
|
|
452
|
+
inputSchema: {
|
|
453
|
+
programId: z.number(),
|
|
454
|
+
date: z.string(),
|
|
455
|
+
pwId: z.number()
|
|
456
|
+
},
|
|
457
|
+
annotations: {
|
|
458
|
+
readOnlyHint: true,
|
|
459
|
+
openWorldHint: true
|
|
460
|
+
}
|
|
461
|
+
}, ({ programId, date, pwId }) => attempt(async () => jsonResult(await readSession(ctx.client, programId, parseWorkoutDate(date), pwId))));
|
|
462
|
+
server.registerTool("workout_publish", {
|
|
463
|
+
title: "Publish a session",
|
|
464
|
+
description: "Publish a built session — ATHLETE-FACING and immediate. Requires confirmation (elicitation, or confirm:true).",
|
|
465
|
+
inputSchema: {
|
|
466
|
+
programId: z.number(),
|
|
467
|
+
date: z.string(),
|
|
468
|
+
pwId: z.number(),
|
|
469
|
+
confirm: z.boolean().optional()
|
|
470
|
+
},
|
|
471
|
+
annotations: {
|
|
472
|
+
readOnlyHint: false,
|
|
473
|
+
destructiveHint: true,
|
|
474
|
+
openWorldHint: true
|
|
475
|
+
}
|
|
476
|
+
}, ({ programId, date, pwId, confirm }, extra) => attempt(async () => {
|
|
477
|
+
if (!await confirmGate(server, extra.requestId, `Publish session ${pwId} on ${date}? This is athlete-facing and immediate.`, confirm)) return errorResult(NOT_CONFIRMED);
|
|
478
|
+
await publishSession(ctx.client, pwId);
|
|
479
|
+
return jsonResult({
|
|
480
|
+
published: pwId,
|
|
481
|
+
readback: await readSession(ctx.client, programId, parseWorkoutDate(date), pwId)
|
|
482
|
+
});
|
|
483
|
+
}));
|
|
484
|
+
server.registerTool("session_remove", {
|
|
485
|
+
title: "Remove a session",
|
|
486
|
+
description: "Delete a session from the live calendar (also the way to replace a date: remove then build). Hard to undo. Requires confirmation (elicitation, or confirm:true).",
|
|
487
|
+
inputSchema: {
|
|
488
|
+
programId: z.number(),
|
|
489
|
+
pwId: z.number(),
|
|
490
|
+
confirm: z.boolean().optional()
|
|
491
|
+
},
|
|
492
|
+
annotations: {
|
|
493
|
+
readOnlyHint: false,
|
|
494
|
+
destructiveHint: true,
|
|
495
|
+
openWorldHint: true
|
|
496
|
+
}
|
|
497
|
+
}, ({ programId, pwId, confirm }, extra) => attempt(async () => {
|
|
498
|
+
if (!await confirmGate(server, extra.requestId, `Delete session ${pwId}? This removes it from the live calendar and is hard to undo.`, confirm)) return errorResult(NOT_CONFIRMED);
|
|
499
|
+
await removeSession(ctx.client, programId, pwId);
|
|
500
|
+
return jsonResult({ removed: pwId });
|
|
501
|
+
}));
|
|
502
|
+
}
|
|
503
|
+
//#endregion
|
|
504
|
+
//#region src/tools/messaging.ts
|
|
505
|
+
function registerReads(server, ctx) {
|
|
506
|
+
server.registerTool("messaging_conversations", {
|
|
507
|
+
title: "List conversations (live)",
|
|
508
|
+
description: "List chat streams (id, kind, title) live from the API; no setup needed. Use the id to read/draft/send.",
|
|
509
|
+
inputSchema: {},
|
|
510
|
+
annotations: READ
|
|
511
|
+
}, () => attempt(async () => {
|
|
512
|
+
return jsonResult((await fetchStreams(ctx.client)).map(({ stream, kind }) => ({
|
|
513
|
+
id: stream.id,
|
|
514
|
+
kind,
|
|
515
|
+
title: stream.title ?? "",
|
|
516
|
+
teamId: stream.teamId,
|
|
517
|
+
userId: stream.userId
|
|
518
|
+
})));
|
|
519
|
+
}));
|
|
520
|
+
server.registerTool("messaging_read", {
|
|
521
|
+
title: "Read messages (live)",
|
|
522
|
+
description: "Recent comments in a stream, live from the API (the stream is fetched whole, then trimmed to `limit`).",
|
|
523
|
+
inputSchema: {
|
|
524
|
+
streamId: idParam,
|
|
525
|
+
limit: z.number().int().positive().max(200).optional()
|
|
526
|
+
},
|
|
527
|
+
annotations: READ
|
|
528
|
+
}, ({ streamId, limit }) => attempt(async () => jsonResult(await readLive(ctx.client, toId(streamId), limit ?? 20))));
|
|
529
|
+
server.registerTool("message_draft", {
|
|
530
|
+
title: "Draft a message (preview only)",
|
|
531
|
+
description: "Preview the exact payload and target WITHOUT sending. Always safe.",
|
|
532
|
+
inputSchema: commentDraftSchema.shape,
|
|
533
|
+
annotations: READ
|
|
534
|
+
}, ({ streamId, text, replyTo }) => attempt(async () => {
|
|
535
|
+
const id = toId(streamId);
|
|
536
|
+
return jsonResult({
|
|
537
|
+
draft: true,
|
|
538
|
+
note: "NOT sent. This is a preview. Run message_send to deliver it.",
|
|
539
|
+
would_POST: `/v5/messaging/streams/${id}/comments`,
|
|
540
|
+
payload: buildCommentPayload(id, text, replyTo === void 0 ? null : toId(replyTo))
|
|
541
|
+
});
|
|
542
|
+
}));
|
|
543
|
+
}
|
|
544
|
+
function registerWrites(server, ctx) {
|
|
545
|
+
server.registerTool("message_send", {
|
|
546
|
+
title: "Send a message",
|
|
547
|
+
description: "Send a chat message — ATHLETE-FACING and immediate (no draft state on the server). Requires confirmation (elicitation, or confirm:true). Prefer message_draft first.",
|
|
548
|
+
inputSchema: {
|
|
549
|
+
...commentDraftSchema.shape,
|
|
550
|
+
confirm: z.boolean().optional()
|
|
551
|
+
},
|
|
552
|
+
annotations: DESTRUCTIVE
|
|
553
|
+
}, ({ streamId, text, replyTo, confirm }, extra) => attempt(async () => {
|
|
554
|
+
const id = toId(streamId);
|
|
555
|
+
if (!await confirmGate(server, extra.requestId, `Send this message to stream ${id}? It is athlete-facing and immediate.`, confirm)) return errorResult(NOT_CONFIRMED);
|
|
556
|
+
return jsonResult({
|
|
557
|
+
sent: true,
|
|
558
|
+
comment: await sendComment(ctx.client, id, text, replyTo === void 0 ? null : toId(replyTo))
|
|
559
|
+
});
|
|
560
|
+
}));
|
|
561
|
+
server.registerTool("message_delete", {
|
|
562
|
+
title: "Delete a message",
|
|
563
|
+
description: "Soft-delete a chat message on the live account. Requires confirmation.",
|
|
564
|
+
inputSchema: {
|
|
565
|
+
streamId: idParam,
|
|
566
|
+
commentId: idParam,
|
|
567
|
+
confirm: z.boolean().optional()
|
|
568
|
+
},
|
|
569
|
+
annotations: DESTRUCTIVE
|
|
570
|
+
}, ({ streamId, commentId, confirm }, extra) => attempt(async () => {
|
|
571
|
+
if (!await confirmGate(server, extra.requestId, `Delete comment ${toId(commentId)} from stream ${toId(streamId)}? Acts on the live account.`, confirm)) return errorResult(NOT_CONFIRMED);
|
|
572
|
+
return jsonResult({
|
|
573
|
+
deleted: true,
|
|
574
|
+
response: await deleteComment(ctx.client, toId(streamId), toId(commentId))
|
|
575
|
+
});
|
|
576
|
+
}));
|
|
577
|
+
}
|
|
578
|
+
/** Live messaging: list/read conversations, draft a message, and the gated send/delete. */
|
|
579
|
+
function registerMessagingTools(server, ctx) {
|
|
580
|
+
registerReads(server, ctx);
|
|
581
|
+
registerWrites(server, ctx);
|
|
582
|
+
}
|
|
583
|
+
//#endregion
|
|
584
|
+
export { DEFAULT_RESULT_BUDGET, DESTRUCTIVE, NOT_CONFIRMED, READ, SYNC, apiCall, attempt, boundedSerialize, confirmGate, errorResult, idParam, jsonResult, registerExerciseTools, registerMessagingTools, registerRawTools, registerReadTools, registerWorkoutTools, resultBudget, toId };
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@trainheroic-unofficial/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/alandotcom/trainheroic-skill.git",
|
|
8
|
+
"directory": "packages/core"
|
|
9
|
+
},
|
|
10
|
+
"description": "Shared MCP tool layer for TrainHeroic, used by the local and Cloudflare servers.",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.mts",
|
|
15
|
+
"import": "./dist/index.mjs"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@cfworker/json-schema": "^4.1.1",
|
|
26
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
27
|
+
"zod": "^4.4.3",
|
|
28
|
+
"@trainheroic-unofficial/dto": "0.1.0",
|
|
29
|
+
"@trainheroic-unofficial/js": "0.1.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^26.0.0",
|
|
33
|
+
"tsdown": "^0.22.3",
|
|
34
|
+
"typescript": "^6.0.3",
|
|
35
|
+
"vitest": "^4.1.9"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsdown",
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"test": "vitest run"
|
|
41
|
+
}
|
|
42
|
+
}
|