@tymio/mcp-server 1.0.0 → 2.0.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/CHANGELOG.md +30 -0
- package/README.md +151 -41
- package/TYMIO_MCP_CLI_AGENT_GUIDANCE.md +100 -0
- package/dist/api.d.ts +2 -0
- package/dist/api.js +14 -2
- package/dist/apiKeyStdio.d.ts +1 -0
- package/dist/apiKeyStdio.js +332 -0
- package/dist/apiKeyTenantResolve.d.ts +4 -0
- package/dist/apiKeyTenantResolve.js +17 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +40 -0
- package/dist/cliMessages.d.ts +7 -0
- package/dist/cliMessages.js +57 -0
- package/dist/configPaths.d.ts +6 -0
- package/dist/configPaths.js +32 -0
- package/dist/fileOAuthProvider.d.ts +27 -0
- package/dist/fileOAuthProvider.js +127 -0
- package/dist/hubProxyStdio.d.ts +4 -0
- package/dist/hubProxyStdio.js +64 -0
- package/dist/index.js +2 -279
- package/dist/loginCommand.d.ts +1 -0
- package/dist/loginCommand.js +30 -0
- package/dist/oauthCallbackServer.d.ts +10 -0
- package/dist/oauthCallbackServer.js +89 -0
- package/dist/persona.d.ts +16 -0
- package/dist/persona.js +93 -0
- package/dist/stdioHints.d.ts +5 -0
- package/dist/stdioHints.js +21 -0
- package/dist/workspaceSlug.d.ts +13 -0
- package/dist/workspaceSlug.js +53 -0
- package/package.json +19 -5
- package/personas/dev.md +33 -0
- package/personas/pm.md +31 -0
- package/personas/po.md +35 -0
- package/personas/workspace.md +41 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST/API-key stdio bridge (subset of hub tools). Set DRD_API_BASE_URL + DRD_API_KEY (or API_KEY).
|
|
3
|
+
* Requires TYMIO_WORKSPACE_SLUG or DRD_WORKSPACE_SLUG (unless TYMIO_MCP_SKIP_WORKSPACE_PINNING=1 for tests).
|
|
4
|
+
*/
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
+
import { resolveTenantIdForWorkspaceSlug } from "./apiKeyTenantResolve.js";
|
|
9
|
+
import { drdFetch, drdFetchText, getBaseUrl, hasApiKey, setApiKeyBridgeTenantId } from "./api.js";
|
|
10
|
+
import { getMcpServerInstructions } from "./persona.js";
|
|
11
|
+
import { toolTextWithFeedback } from "./mcpFeedbackFooter.js";
|
|
12
|
+
import { writeStdioStartupHint } from "./stdioHints.js";
|
|
13
|
+
import { assertToolArgsMatchPinnedWorkspace, omitWorkspaceSlug, readPinnedWorkspaceSlugForStdio, WORKSPACE_SLUG_ZOD } from "./workspaceSlug.js";
|
|
14
|
+
export async function runApiKeyStdio() {
|
|
15
|
+
writeStdioStartupHint("api-key");
|
|
16
|
+
const pinnedSlug = readPinnedWorkspaceSlugForStdio();
|
|
17
|
+
if (pinnedSlug) {
|
|
18
|
+
try {
|
|
19
|
+
const tenantId = await resolveTenantIdForWorkspaceSlug(pinnedSlug);
|
|
20
|
+
setApiKeyBridgeTenantId(tenantId);
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
process.stderr.write(`[tymio-mcp] API-key bridge: cannot resolve workspace: ${e}\n`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const server = new McpServer({ name: "tymio-hub", version: "1.0.0" }, { instructions: getMcpServerInstructions() });
|
|
28
|
+
async function textContent(text) {
|
|
29
|
+
return toolTextWithFeedback(getBaseUrl(), text);
|
|
30
|
+
}
|
|
31
|
+
function assertPin(args, tool) {
|
|
32
|
+
if (pinnedSlug)
|
|
33
|
+
assertToolArgsMatchPinnedWorkspace(args, pinnedSlug, tool);
|
|
34
|
+
}
|
|
35
|
+
const ws = { workspaceSlug: WORKSPACE_SLUG_ZOD };
|
|
36
|
+
server.registerTool("drd_health", {
|
|
37
|
+
title: "Tymio API health check",
|
|
38
|
+
description: "Check if the Tymio hub API is reachable. Requires workspaceSlug (must match server pin).",
|
|
39
|
+
inputSchema: z.object(ws)
|
|
40
|
+
}, async (args) => {
|
|
41
|
+
assertPin(args, "drd_health");
|
|
42
|
+
const data = await drdFetch("/api/health");
|
|
43
|
+
return textContent(JSON.stringify(data));
|
|
44
|
+
});
|
|
45
|
+
server.registerTool("drd_meta", {
|
|
46
|
+
title: "Get Tymio meta",
|
|
47
|
+
description: "Get meta data: domains, products, accounts, partners, personas, revenue streams, users.",
|
|
48
|
+
inputSchema: z.object(ws)
|
|
49
|
+
}, async (args) => {
|
|
50
|
+
assertPin(args, "drd_meta");
|
|
51
|
+
const data = await drdFetch("/api/meta");
|
|
52
|
+
return textContent(JSON.stringify(data, null, 2));
|
|
53
|
+
});
|
|
54
|
+
const listInitiativesSchema = z
|
|
55
|
+
.object({
|
|
56
|
+
domainId: z.string().optional(),
|
|
57
|
+
ownerId: z.string().optional(),
|
|
58
|
+
horizon: z.enum(["NOW", "NEXT", "LATER"]).optional(),
|
|
59
|
+
priority: z.enum(["P0", "P1", "P2", "P3"]).optional(),
|
|
60
|
+
isGap: z.boolean().optional()
|
|
61
|
+
})
|
|
62
|
+
.extend(ws);
|
|
63
|
+
server.registerTool("drd_list_initiatives", {
|
|
64
|
+
title: "List initiatives",
|
|
65
|
+
description: "List initiatives with optional filters: domainId, ownerId, horizon, priority, isGap.",
|
|
66
|
+
inputSchema: listInitiativesSchema
|
|
67
|
+
}, async (args) => {
|
|
68
|
+
assertPin(args, "drd_list_initiatives");
|
|
69
|
+
const { workspaceSlug: _w, ...filters } = args;
|
|
70
|
+
const params = new URLSearchParams();
|
|
71
|
+
if (filters.domainId)
|
|
72
|
+
params.set("domainId", filters.domainId);
|
|
73
|
+
if (filters.ownerId)
|
|
74
|
+
params.set("ownerId", filters.ownerId);
|
|
75
|
+
if (filters.horizon)
|
|
76
|
+
params.set("horizon", filters.horizon);
|
|
77
|
+
if (filters.priority)
|
|
78
|
+
params.set("priority", filters.priority);
|
|
79
|
+
if (filters.isGap !== undefined)
|
|
80
|
+
params.set("isGap", String(filters.isGap));
|
|
81
|
+
const data = await drdFetch(`/api/initiatives?${params.toString()}`);
|
|
82
|
+
return textContent(JSON.stringify(data.initiatives, null, 2));
|
|
83
|
+
});
|
|
84
|
+
server.registerTool("drd_get_initiative", {
|
|
85
|
+
title: "Get initiative by ID",
|
|
86
|
+
description: "Get a single initiative by its ID.",
|
|
87
|
+
inputSchema: z.object({ id: z.string().describe("Initiative ID") }).extend(ws)
|
|
88
|
+
}, async (args) => {
|
|
89
|
+
assertPin(args, "drd_get_initiative");
|
|
90
|
+
const { id } = omitWorkspaceSlug(args);
|
|
91
|
+
const data = await drdFetch(`/api/initiatives/${id}`);
|
|
92
|
+
return textContent(JSON.stringify(data.initiative, null, 2));
|
|
93
|
+
});
|
|
94
|
+
server.registerTool("drd_create_initiative", {
|
|
95
|
+
title: "Create initiative",
|
|
96
|
+
description: "Create a new initiative. Requires admin/editor role.",
|
|
97
|
+
inputSchema: z
|
|
98
|
+
.object({
|
|
99
|
+
title: z.string(),
|
|
100
|
+
domainId: z.string(),
|
|
101
|
+
description: z.string().optional(),
|
|
102
|
+
ownerId: z.string().optional(),
|
|
103
|
+
priority: z.enum(["P0", "P1", "P2", "P3"]).optional(),
|
|
104
|
+
horizon: z.enum(["NOW", "NEXT", "LATER"]).optional(),
|
|
105
|
+
status: z.enum(["IDEA", "PLANNED", "IN_PROGRESS", "DONE", "BLOCKED"]).optional(),
|
|
106
|
+
commercialType: z.string().optional(),
|
|
107
|
+
isGap: z.boolean().optional()
|
|
108
|
+
})
|
|
109
|
+
.extend(ws)
|
|
110
|
+
}, async (args) => {
|
|
111
|
+
assertPin(args, "drd_create_initiative");
|
|
112
|
+
const body = omitWorkspaceSlug(args);
|
|
113
|
+
const data = await drdFetch("/api/initiatives", {
|
|
114
|
+
method: "POST",
|
|
115
|
+
body: JSON.stringify(body)
|
|
116
|
+
});
|
|
117
|
+
return textContent(JSON.stringify(data.initiative, null, 2));
|
|
118
|
+
});
|
|
119
|
+
server.registerTool("drd_update_initiative", {
|
|
120
|
+
title: "Update initiative",
|
|
121
|
+
description: "Update an existing initiative by ID.",
|
|
122
|
+
inputSchema: z
|
|
123
|
+
.object({
|
|
124
|
+
id: z.string(),
|
|
125
|
+
title: z.string().optional(),
|
|
126
|
+
domainId: z.string().optional(),
|
|
127
|
+
description: z.string().optional(),
|
|
128
|
+
ownerId: z.string().optional(),
|
|
129
|
+
priority: z.enum(["P0", "P1", "P2", "P3"]).optional(),
|
|
130
|
+
horizon: z.enum(["NOW", "NEXT", "LATER"]).optional(),
|
|
131
|
+
status: z.enum(["IDEA", "PLANNED", "IN_PROGRESS", "DONE", "BLOCKED"]).optional(),
|
|
132
|
+
commercialType: z.string().optional(),
|
|
133
|
+
isGap: z.boolean().optional()
|
|
134
|
+
})
|
|
135
|
+
.extend(ws)
|
|
136
|
+
}, async (args) => {
|
|
137
|
+
assertPin(args, "drd_update_initiative");
|
|
138
|
+
const { id, ...body } = omitWorkspaceSlug(args);
|
|
139
|
+
const data = await drdFetch(`/api/initiatives/${id}`, {
|
|
140
|
+
method: "PUT",
|
|
141
|
+
body: JSON.stringify(body)
|
|
142
|
+
});
|
|
143
|
+
return textContent(JSON.stringify(data.initiative, null, 2));
|
|
144
|
+
});
|
|
145
|
+
server.registerTool("drd_delete_initiative", {
|
|
146
|
+
title: "Delete initiative",
|
|
147
|
+
description: "Delete an initiative by ID.",
|
|
148
|
+
inputSchema: z.object({ id: z.string() }).extend(ws)
|
|
149
|
+
}, async (args) => {
|
|
150
|
+
assertPin(args, "drd_delete_initiative");
|
|
151
|
+
const { id } = omitWorkspaceSlug(args);
|
|
152
|
+
await drdFetch(`/api/initiatives/${id}`, { method: "DELETE" });
|
|
153
|
+
return textContent(JSON.stringify({ ok: true }));
|
|
154
|
+
});
|
|
155
|
+
server.registerTool("drd_list_domains", {
|
|
156
|
+
title: "List domains",
|
|
157
|
+
description: "List all domains.",
|
|
158
|
+
inputSchema: z.object(ws)
|
|
159
|
+
}, async (args) => {
|
|
160
|
+
assertPin(args, "drd_list_domains");
|
|
161
|
+
const data = await drdFetch("/api/domains");
|
|
162
|
+
return textContent(JSON.stringify(data.domains, null, 2));
|
|
163
|
+
});
|
|
164
|
+
server.registerTool("drd_create_domain", {
|
|
165
|
+
title: "Create domain",
|
|
166
|
+
description: "Create a new domain (pillar). Requires workspace OWNER or ADMIN.",
|
|
167
|
+
inputSchema: z
|
|
168
|
+
.object({
|
|
169
|
+
name: z.string().min(1),
|
|
170
|
+
color: z.string().min(1),
|
|
171
|
+
sortOrder: z.number().int().optional()
|
|
172
|
+
})
|
|
173
|
+
.extend(ws)
|
|
174
|
+
}, async (args) => {
|
|
175
|
+
assertPin(args, "drd_create_domain");
|
|
176
|
+
const body = omitWorkspaceSlug(args);
|
|
177
|
+
const data = await drdFetch("/api/domains", {
|
|
178
|
+
method: "POST",
|
|
179
|
+
body: JSON.stringify({
|
|
180
|
+
name: body.name,
|
|
181
|
+
color: body.color,
|
|
182
|
+
sortOrder: body.sortOrder ?? 0
|
|
183
|
+
})
|
|
184
|
+
});
|
|
185
|
+
return textContent(JSON.stringify(data.domain, null, 2));
|
|
186
|
+
});
|
|
187
|
+
server.registerTool("drd_list_products", {
|
|
188
|
+
title: "List products",
|
|
189
|
+
description: "List all products (with hierarchy).",
|
|
190
|
+
inputSchema: z.object(ws)
|
|
191
|
+
}, async (args) => {
|
|
192
|
+
assertPin(args, "drd_list_products");
|
|
193
|
+
const data = await drdFetch("/api/products");
|
|
194
|
+
return textContent(JSON.stringify(data.products, null, 2));
|
|
195
|
+
});
|
|
196
|
+
server.registerTool("drd_list_personas", {
|
|
197
|
+
title: "List personas",
|
|
198
|
+
description: "List all personas.",
|
|
199
|
+
inputSchema: z.object(ws)
|
|
200
|
+
}, async (args) => {
|
|
201
|
+
assertPin(args, "drd_list_personas");
|
|
202
|
+
const data = await drdFetch("/api/personas");
|
|
203
|
+
return textContent(JSON.stringify(data.personas, null, 2));
|
|
204
|
+
});
|
|
205
|
+
server.registerTool("drd_list_accounts", {
|
|
206
|
+
title: "List accounts",
|
|
207
|
+
description: "List all accounts.",
|
|
208
|
+
inputSchema: z.object(ws)
|
|
209
|
+
}, async (args) => {
|
|
210
|
+
assertPin(args, "drd_list_accounts");
|
|
211
|
+
const data = await drdFetch("/api/accounts");
|
|
212
|
+
return textContent(JSON.stringify(data.accounts, null, 2));
|
|
213
|
+
});
|
|
214
|
+
server.registerTool("drd_list_partners", {
|
|
215
|
+
title: "List partners",
|
|
216
|
+
description: "List all partners.",
|
|
217
|
+
inputSchema: z.object(ws)
|
|
218
|
+
}, async (args) => {
|
|
219
|
+
assertPin(args, "drd_list_partners");
|
|
220
|
+
const data = await drdFetch("/api/partners");
|
|
221
|
+
return textContent(JSON.stringify(data.partners, null, 2));
|
|
222
|
+
});
|
|
223
|
+
server.registerTool("drd_list_kpis", {
|
|
224
|
+
title: "List KPIs",
|
|
225
|
+
description: "List all initiative KPIs with their initiative context (title, domain, owner).",
|
|
226
|
+
inputSchema: z.object(ws)
|
|
227
|
+
}, async (args) => {
|
|
228
|
+
assertPin(args, "drd_list_kpis");
|
|
229
|
+
const data = await drdFetch("/api/kpis");
|
|
230
|
+
return textContent(JSON.stringify(data.kpis, null, 2));
|
|
231
|
+
});
|
|
232
|
+
server.registerTool("drd_list_milestones", {
|
|
233
|
+
title: "List milestones",
|
|
234
|
+
description: "List all initiative milestones with their initiative context.",
|
|
235
|
+
inputSchema: z.object(ws)
|
|
236
|
+
}, async (args) => {
|
|
237
|
+
assertPin(args, "drd_list_milestones");
|
|
238
|
+
const data = await drdFetch("/api/milestones");
|
|
239
|
+
return textContent(JSON.stringify(data.milestones, null, 2));
|
|
240
|
+
});
|
|
241
|
+
server.registerTool("drd_list_demands", {
|
|
242
|
+
title: "List demands",
|
|
243
|
+
description: "List all demands (from accounts, partners, internal, compliance).",
|
|
244
|
+
inputSchema: z.object(ws)
|
|
245
|
+
}, async (args) => {
|
|
246
|
+
assertPin(args, "drd_list_demands");
|
|
247
|
+
const data = await drdFetch("/api/demands");
|
|
248
|
+
return textContent(JSON.stringify(data.demands, null, 2));
|
|
249
|
+
});
|
|
250
|
+
server.registerTool("drd_list_revenue_streams", {
|
|
251
|
+
title: "List revenue streams",
|
|
252
|
+
description: "List all revenue streams.",
|
|
253
|
+
inputSchema: z.object(ws)
|
|
254
|
+
}, async (args) => {
|
|
255
|
+
assertPin(args, "drd_list_revenue_streams");
|
|
256
|
+
const data = await drdFetch("/api/revenue-streams");
|
|
257
|
+
return textContent(JSON.stringify(data.revenueStreams, null, 2));
|
|
258
|
+
});
|
|
259
|
+
server.registerTool("tymio_get_coding_agent_guide", {
|
|
260
|
+
title: "Get Tymio coding agent playbook (Markdown)",
|
|
261
|
+
description: "Full docs/CODING_AGENT_TYMIO.md: MCP usage, as-is to Tymio, feature lifecycle. Call at session start when automating this hub.",
|
|
262
|
+
inputSchema: z.object(ws)
|
|
263
|
+
}, async (args) => {
|
|
264
|
+
assertPin(args, "tymio_get_coding_agent_guide");
|
|
265
|
+
const md = await drdFetchText("/api/agent/coding-guide");
|
|
266
|
+
return textContent(md);
|
|
267
|
+
});
|
|
268
|
+
server.registerTool("tymio_get_agent_brief", {
|
|
269
|
+
title: "Get compiled agent capability brief",
|
|
270
|
+
description: "Returns the hub capability ontology as Markdown or JSON. mode=compact|full, format=md|json.",
|
|
271
|
+
inputSchema: z
|
|
272
|
+
.object({
|
|
273
|
+
mode: z.enum(["compact", "full"]).default("compact"),
|
|
274
|
+
format: z.enum(["md", "json"]).default("md")
|
|
275
|
+
})
|
|
276
|
+
.extend(ws)
|
|
277
|
+
}, async (args) => {
|
|
278
|
+
assertPin(args, "tymio_get_agent_brief");
|
|
279
|
+
const { mode, format } = omitWorkspaceSlug(args);
|
|
280
|
+
const params = new URLSearchParams({ mode, format });
|
|
281
|
+
const q = params.toString();
|
|
282
|
+
const raw = await drdFetchText(`/api/ontology/brief?${q}`);
|
|
283
|
+
if (format === "json") {
|
|
284
|
+
try {
|
|
285
|
+
const parsed = JSON.parse(raw);
|
|
286
|
+
return textContent(JSON.stringify(parsed, null, 2));
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return textContent(raw);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return textContent(raw);
|
|
293
|
+
});
|
|
294
|
+
server.registerTool("tymio_list_capabilities", {
|
|
295
|
+
title: "List hub capabilities (ontology)",
|
|
296
|
+
description: "Optional status: ACTIVE, DRAFT, DEPRECATED.",
|
|
297
|
+
inputSchema: z.object({ status: z.enum(["ACTIVE", "DRAFT", "DEPRECATED"]).optional() }).extend(ws)
|
|
298
|
+
}, async (args) => {
|
|
299
|
+
assertPin(args, "tymio_list_capabilities");
|
|
300
|
+
const { status } = omitWorkspaceSlug(args);
|
|
301
|
+
const params = new URLSearchParams();
|
|
302
|
+
if (status)
|
|
303
|
+
params.set("status", status);
|
|
304
|
+
const q = params.toString();
|
|
305
|
+
const data = await drdFetch(`/api/ontology/capabilities${q ? `?${q}` : ""}`);
|
|
306
|
+
return textContent(JSON.stringify(data, null, 2));
|
|
307
|
+
});
|
|
308
|
+
server.registerTool("tymio_get_capability", {
|
|
309
|
+
title: "Get one capability by id or slug",
|
|
310
|
+
description: "Provide id or slug.",
|
|
311
|
+
inputSchema: z
|
|
312
|
+
.object({ id: z.string().optional(), slug: z.string().optional() })
|
|
313
|
+
.extend(ws)
|
|
314
|
+
}, async (args) => {
|
|
315
|
+
assertPin(args, "tymio_get_capability");
|
|
316
|
+
const { id, slug } = omitWorkspaceSlug(args);
|
|
317
|
+
if (id) {
|
|
318
|
+
const data = await drdFetch(`/api/ontology/capabilities/${id}`);
|
|
319
|
+
return textContent(JSON.stringify(data, null, 2));
|
|
320
|
+
}
|
|
321
|
+
if (slug) {
|
|
322
|
+
const data = await drdFetch(`/api/ontology/capabilities/by-slug/${encodeURIComponent(slug)}`);
|
|
323
|
+
return textContent(JSON.stringify(data, null, 2));
|
|
324
|
+
}
|
|
325
|
+
throw new Error("Provide id or slug");
|
|
326
|
+
});
|
|
327
|
+
if (!hasApiKey()) {
|
|
328
|
+
process.stderr.write("Warning: DRD_API_KEY is not set. Authenticated API calls will fail. Set DRD_API_KEY and API_KEY on the server.\n");
|
|
329
|
+
}
|
|
330
|
+
const transport = new StdioServerTransport();
|
|
331
|
+
await server.connect(transport);
|
|
332
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { drdFetch } from "./api.js";
|
|
2
|
+
import { isValidWorkspaceSlugFormat } from "./workspaceSlug.js";
|
|
3
|
+
/**
|
|
4
|
+
* Resolve workspace slug to tenant id for API-key bridge; verifies ACTIVE membership for the API key user.
|
|
5
|
+
*/
|
|
6
|
+
export async function resolveTenantIdForWorkspaceSlug(expectedSlug) {
|
|
7
|
+
if (!isValidWorkspaceSlugFormat(expectedSlug)) {
|
|
8
|
+
throw new Error(`Invalid workspace slug: ${JSON.stringify(expectedSlug)}`);
|
|
9
|
+
}
|
|
10
|
+
const want = expectedSlug.toLowerCase();
|
|
11
|
+
const data = await drdFetch("/api/me/tenants");
|
|
12
|
+
const row = data.tenants.find((m) => m.tenant.slug.toLowerCase() === want && m.tenant.status === "ACTIVE");
|
|
13
|
+
if (!row) {
|
|
14
|
+
throw new Error(`API key user has no ACTIVE membership for workspace slug "${expectedSlug}". Check TYMIO_WORKSPACE_SLUG and hub memberships.`);
|
|
15
|
+
}
|
|
16
|
+
return row.tenant.id;
|
|
17
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runCli(argv: string[]): Promise<void>;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { defaultMcpUrl } from "./configPaths.js";
|
|
2
|
+
import { AGENT_INSTRUCTIONS, HELP_SUMMARY } from "./cliMessages.js";
|
|
3
|
+
import { runApiKeyStdio } from "./apiKeyStdio.js";
|
|
4
|
+
import { runHubOAuthStdio } from "./hubProxyStdio.js";
|
|
5
|
+
import { runLoginCommand } from "./loginCommand.js";
|
|
6
|
+
import { removeAllOAuthFiles } from "./fileOAuthProvider.js";
|
|
7
|
+
import { runPersonaCli } from "./persona.js";
|
|
8
|
+
function useApiKeyBridge() {
|
|
9
|
+
return Boolean(process.env.DRD_API_KEY?.trim() || process.env.API_KEY?.trim());
|
|
10
|
+
}
|
|
11
|
+
export async function runCli(argv) {
|
|
12
|
+
const args = argv.slice(2).filter((a) => a !== "--");
|
|
13
|
+
if (args[0] === "login") {
|
|
14
|
+
const url = args[1] ? new URL(args[1]) : defaultMcpUrl();
|
|
15
|
+
await runLoginCommand(url);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (args[0] === "logout") {
|
|
19
|
+
removeAllOAuthFiles();
|
|
20
|
+
process.stderr.write("Removed stored Tymio MCP OAuth credentials.\n");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (args[0] === "instructions" || args[0] === "guide") {
|
|
24
|
+
process.stderr.write(`${AGENT_INSTRUCTIONS}\n`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (args[0] === "help" || args[0] === "-h" || args[0] === "--help") {
|
|
28
|
+
process.stderr.write(`${HELP_SUMMARY}\n`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (args[0] === "persona") {
|
|
32
|
+
process.exitCode = runPersonaCli(args.slice(1));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (useApiKeyBridge()) {
|
|
36
|
+
await runApiKeyStdio();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
await runHubOAuthStdio();
|
|
40
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Short usage (stderr) — keep in sync with guidance file for agents. */
|
|
2
|
+
export declare const HELP_SUMMARY = "Tymio MCP CLI (@tymio/mcp-server)\n\nCommands:\n tymio-mcp Start stdio MCP (default: OAuth \u2192 hosted Tymio MCP)\n tymio-mcp login [url] Sign in with Google (browser). Saves tokens locally.\n tymio-mcp logout Delete saved OAuth client + tokens\n tymio-mcp instructions Full setup text for humans & coding agents (print this)\n tymio-mcp persona list Bundled PM/PO/DEV/workspace prompts (see also TYMIO_MCP_PERSONA)\n tymio-mcp persona <id> Print one persona Markdown to stdout (pm | po | dev | workspace)\n tymio-mcp help This summary\n\nEnvironment:\n TYMIO_MCP_URL Hosted MCP URL (default https://tymio.app/mcp)\n TYMIO_OAUTH_PORT Loopback port for login callback (default 19876)\n TYMIO_MCP_QUIET If set, suppress stderr hints when starting stdio\n TYMIO_WORKSPACE_SLUG Required for stdio (or DRD_WORKSPACE_SLUG): hub workspace slug this process is pinned to; every tool call must use the same slug (tests: TYMIO_MCP_SKIP_WORKSPACE_PINNING=1)\n TYMIO_MCP_PERSONA Optional: pm | po | dev | workspace (hub aliases workspace) \u2014 appended to MCP server instructions\n DRD_API_KEY / API_KEY If set \u2192 API-key REST tool bridge (subset), not OAuth proxy\n DRD_API_BASE_URL Hub origin for API-key bridge (default https://tymio.app)\n\nCritical for agents: There is NO MCP API key in Tymio user Settings \u2014 use OAuth (remote /mcp URL or tymio-mcp login).\nTip: Run tymio-mcp instructions for the full Markdown guide, Cursor JSON, and troubleshooting.\n";
|
|
3
|
+
/**
|
|
4
|
+
* Long-form instructions for coding agents and operators (Markdown).
|
|
5
|
+
* Loaded from `TYMIO_MCP_CLI_AGENT_GUIDANCE.md` beside the installed package / dist output.
|
|
6
|
+
*/
|
|
7
|
+
export declare const AGENT_INSTRUCTIONS: string;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
const GUIDANCE_FILE = "TYMIO_MCP_CLI_AGENT_GUIDANCE.md";
|
|
5
|
+
function loadAgentGuidanceMarkdown() {
|
|
6
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const packaged = path.join(here, "..", GUIDANCE_FILE);
|
|
8
|
+
try {
|
|
9
|
+
if (existsSync(packaged))
|
|
10
|
+
return readFileSync(packaged, "utf8");
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
/* fall through */
|
|
14
|
+
}
|
|
15
|
+
return [
|
|
16
|
+
"# Tymio MCP CLI",
|
|
17
|
+
"",
|
|
18
|
+
"The guidance file `TYMIO_MCP_CLI_AGENT_GUIDANCE.md` was not found next to this package (expected beside `dist/`). Reinstall `@tymio/mcp-server` or run from the repository checkout.",
|
|
19
|
+
"",
|
|
20
|
+
"## Critical (read before advising the user)",
|
|
21
|
+
"",
|
|
22
|
+
"- **No per-user MCP API key** exists in Tymio Settings, Profile, or Account. Do not tell users to copy one from the UI.",
|
|
23
|
+
"- **OAuth:** remote MCP URL `https://tymio.app/mcp` (or your host) in the IDE, or install `@tymio/mcp-server` and run `tymio-mcp login` for stdio **without** `DRD_API_KEY`/`API_KEY` on that process unless you want API-key mode.",
|
|
24
|
+
"- **`DRD_API_KEY`/`API_KEY`** on the stdio process is the **server deployment** automation secret, not a personal user key.",
|
|
25
|
+
"",
|
|
26
|
+
"After fixing the install: `tymio-mcp instructions`",
|
|
27
|
+
].join("\n");
|
|
28
|
+
}
|
|
29
|
+
/** Short usage (stderr) — keep in sync with guidance file for agents. */
|
|
30
|
+
export const HELP_SUMMARY = `Tymio MCP CLI (@tymio/mcp-server)
|
|
31
|
+
|
|
32
|
+
Commands:
|
|
33
|
+
tymio-mcp Start stdio MCP (default: OAuth → hosted Tymio MCP)
|
|
34
|
+
tymio-mcp login [url] Sign in with Google (browser). Saves tokens locally.
|
|
35
|
+
tymio-mcp logout Delete saved OAuth client + tokens
|
|
36
|
+
tymio-mcp instructions Full setup text for humans & coding agents (print this)
|
|
37
|
+
tymio-mcp persona list Bundled PM/PO/DEV/workspace prompts (see also TYMIO_MCP_PERSONA)
|
|
38
|
+
tymio-mcp persona <id> Print one persona Markdown to stdout (pm | po | dev | workspace)
|
|
39
|
+
tymio-mcp help This summary
|
|
40
|
+
|
|
41
|
+
Environment:
|
|
42
|
+
TYMIO_MCP_URL Hosted MCP URL (default https://tymio.app/mcp)
|
|
43
|
+
TYMIO_OAUTH_PORT Loopback port for login callback (default 19876)
|
|
44
|
+
TYMIO_MCP_QUIET If set, suppress stderr hints when starting stdio
|
|
45
|
+
TYMIO_WORKSPACE_SLUG Required for stdio (or DRD_WORKSPACE_SLUG): hub workspace slug this process is pinned to; every tool call must use the same slug (tests: TYMIO_MCP_SKIP_WORKSPACE_PINNING=1)
|
|
46
|
+
TYMIO_MCP_PERSONA Optional: pm | po | dev | workspace (hub aliases workspace) — appended to MCP server instructions
|
|
47
|
+
DRD_API_KEY / API_KEY If set → API-key REST tool bridge (subset), not OAuth proxy
|
|
48
|
+
DRD_API_BASE_URL Hub origin for API-key bridge (default https://tymio.app)
|
|
49
|
+
|
|
50
|
+
Critical for agents: There is NO MCP API key in Tymio user Settings — use OAuth (remote /mcp URL or tymio-mcp login).
|
|
51
|
+
Tip: Run tymio-mcp instructions for the full Markdown guide, Cursor JSON, and troubleshooting.
|
|
52
|
+
`;
|
|
53
|
+
/**
|
|
54
|
+
* Long-form instructions for coding agents and operators (Markdown).
|
|
55
|
+
* Loaded from `TYMIO_MCP_CLI_AGENT_GUIDANCE.md` beside the installed package / dist output.
|
|
56
|
+
*/
|
|
57
|
+
export const AGENT_INSTRUCTIONS = loadAgentGuidanceMarkdown();
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** XDG-style config directory for OAuth tokens and dynamic client registration. */
|
|
2
|
+
export declare function getTymioConfigDir(): string;
|
|
3
|
+
export declare function ensureConfigDir(): string;
|
|
4
|
+
export declare function defaultMcpUrl(): URL;
|
|
5
|
+
/** Loopback redirect port for OAuth `login` (must stay stable across runs for dynamic client registration). */
|
|
6
|
+
export declare function defaultOAuthRedirectUrl(): URL;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/** XDG-style config directory for OAuth tokens and dynamic client registration. */
|
|
5
|
+
export function getTymioConfigDir() {
|
|
6
|
+
const base = process.env.XDG_CONFIG_HOME ?? (process.platform === "darwin" ? path.join(os.homedir(), "Library", "Application Support") : path.join(os.homedir(), ".config"));
|
|
7
|
+
return path.join(base, "tymio-mcp");
|
|
8
|
+
}
|
|
9
|
+
export function ensureConfigDir() {
|
|
10
|
+
const dir = getTymioConfigDir();
|
|
11
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
12
|
+
return dir;
|
|
13
|
+
}
|
|
14
|
+
export function defaultMcpUrl() {
|
|
15
|
+
const raw = (process.env.TYMIO_MCP_URL ?? "https://tymio.app/mcp").trim();
|
|
16
|
+
const fallback = "https://tymio.app/mcp";
|
|
17
|
+
const base = raw.length > 0 ? raw : fallback;
|
|
18
|
+
const trimmed = base.replace(/\/+$/, "");
|
|
19
|
+
const withMcp = trimmed.endsWith("/mcp") ? trimmed : `${trimmed}/mcp`;
|
|
20
|
+
return new URL(withMcp);
|
|
21
|
+
}
|
|
22
|
+
/** Loopback redirect port for OAuth `login` (must stay stable across runs for dynamic client registration). */
|
|
23
|
+
export function defaultOAuthRedirectUrl() {
|
|
24
|
+
const raw = process.env.TYMIO_OAUTH_PORT;
|
|
25
|
+
let port = 19876;
|
|
26
|
+
if (raw !== undefined && raw.trim() !== "") {
|
|
27
|
+
const n = Number(raw);
|
|
28
|
+
if (Number.isInteger(n) && n >= 1 && n <= 65535)
|
|
29
|
+
port = n;
|
|
30
|
+
}
|
|
31
|
+
return new URL(`http://127.0.0.1:${port}/callback`);
|
|
32
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { OAuthClientProvider, OAuthDiscoveryState } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
2
|
+
import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
3
|
+
/**
|
|
4
|
+
* Persists MCP OAuth dynamic registration + tokens for the Tymio hub Streamable HTTP endpoint.
|
|
5
|
+
*/
|
|
6
|
+
export declare class FileOAuthProvider implements OAuthClientProvider {
|
|
7
|
+
private readonly dir;
|
|
8
|
+
private readonly redirect;
|
|
9
|
+
private codeVerifierValue?;
|
|
10
|
+
private oauthState?;
|
|
11
|
+
constructor(redirectUrl: URL);
|
|
12
|
+
get redirectUrl(): URL;
|
|
13
|
+
get clientMetadata(): OAuthClientMetadata;
|
|
14
|
+
clientInformation(): OAuthClientInformationMixed | undefined;
|
|
15
|
+
saveClientInformation(clientInformation: OAuthClientInformationMixed): void;
|
|
16
|
+
tokens(): OAuthTokens | undefined;
|
|
17
|
+
saveTokens(tokens: OAuthTokens): void;
|
|
18
|
+
saveCodeVerifier(codeVerifier: string): void;
|
|
19
|
+
codeVerifier(): string;
|
|
20
|
+
state(): Promise<string>;
|
|
21
|
+
clearLoginSession(): void;
|
|
22
|
+
redirectToAuthorization(authorizationUrl: URL): void;
|
|
23
|
+
saveDiscoveryState(state: OAuthDiscoveryState): Promise<void>;
|
|
24
|
+
discoveryState(): Promise<OAuthDiscoveryState | undefined>;
|
|
25
|
+
invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery"): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
export declare function removeAllOAuthFiles(): void;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { ensureConfigDir, getTymioConfigDir } from "./configPaths.js";
|
|
6
|
+
const CLIENT_FILE = "oauth-client.json";
|
|
7
|
+
const TOKENS_FILE = "oauth-tokens.json";
|
|
8
|
+
const DISCOVERY_FILE = "oauth-discovery.json";
|
|
9
|
+
function readJson(file) {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function writeJson(file, data) {
|
|
18
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2), "utf8");
|
|
19
|
+
}
|
|
20
|
+
function openUrlInBrowser(url) {
|
|
21
|
+
if (process.platform === "darwin") {
|
|
22
|
+
spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
23
|
+
}
|
|
24
|
+
else if (process.platform === "win32") {
|
|
25
|
+
spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Persists MCP OAuth dynamic registration + tokens for the Tymio hub Streamable HTTP endpoint.
|
|
33
|
+
*/
|
|
34
|
+
export class FileOAuthProvider {
|
|
35
|
+
dir;
|
|
36
|
+
redirect;
|
|
37
|
+
codeVerifierValue;
|
|
38
|
+
oauthState;
|
|
39
|
+
constructor(redirectUrl) {
|
|
40
|
+
this.dir = ensureConfigDir();
|
|
41
|
+
this.redirect = redirectUrl;
|
|
42
|
+
}
|
|
43
|
+
get redirectUrl() {
|
|
44
|
+
return this.redirect;
|
|
45
|
+
}
|
|
46
|
+
get clientMetadata() {
|
|
47
|
+
return {
|
|
48
|
+
redirect_uris: [this.redirect.toString()],
|
|
49
|
+
client_name: "Tymio MCP CLI",
|
|
50
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
51
|
+
response_types: ["code"],
|
|
52
|
+
token_endpoint_auth_method: "none",
|
|
53
|
+
scope: "mcp:tools"
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
clientInformation() {
|
|
57
|
+
return readJson(path.join(this.dir, CLIENT_FILE));
|
|
58
|
+
}
|
|
59
|
+
saveClientInformation(clientInformation) {
|
|
60
|
+
writeJson(path.join(this.dir, CLIENT_FILE), clientInformation);
|
|
61
|
+
}
|
|
62
|
+
tokens() {
|
|
63
|
+
return readJson(path.join(this.dir, TOKENS_FILE));
|
|
64
|
+
}
|
|
65
|
+
saveTokens(tokens) {
|
|
66
|
+
writeJson(path.join(this.dir, TOKENS_FILE), tokens);
|
|
67
|
+
}
|
|
68
|
+
saveCodeVerifier(codeVerifier) {
|
|
69
|
+
this.codeVerifierValue = codeVerifier;
|
|
70
|
+
}
|
|
71
|
+
codeVerifier() {
|
|
72
|
+
if (!this.codeVerifierValue)
|
|
73
|
+
throw new Error("Missing PKCE code verifier");
|
|
74
|
+
return this.codeVerifierValue;
|
|
75
|
+
}
|
|
76
|
+
async state() {
|
|
77
|
+
if (!this.oauthState) {
|
|
78
|
+
this.oauthState = randomBytes(16).toString("hex");
|
|
79
|
+
}
|
|
80
|
+
return this.oauthState;
|
|
81
|
+
}
|
|
82
|
+
clearLoginSession() {
|
|
83
|
+
this.codeVerifierValue = undefined;
|
|
84
|
+
this.oauthState = undefined;
|
|
85
|
+
}
|
|
86
|
+
redirectToAuthorization(authorizationUrl) {
|
|
87
|
+
process.stderr.write(`Opening browser to sign in to Tymio…\n${authorizationUrl.toString()}\n`);
|
|
88
|
+
openUrlInBrowser(authorizationUrl.toString());
|
|
89
|
+
}
|
|
90
|
+
async saveDiscoveryState(state) {
|
|
91
|
+
writeJson(path.join(this.dir, DISCOVERY_FILE), state);
|
|
92
|
+
}
|
|
93
|
+
async discoveryState() {
|
|
94
|
+
return readJson(path.join(this.dir, DISCOVERY_FILE));
|
|
95
|
+
}
|
|
96
|
+
async invalidateCredentials(scope) {
|
|
97
|
+
const base = getTymioConfigDir();
|
|
98
|
+
const rm = (f) => {
|
|
99
|
+
try {
|
|
100
|
+
fs.unlinkSync(path.join(base, f));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
/* ignore */
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
if (scope === "all" || scope === "tokens")
|
|
107
|
+
rm(TOKENS_FILE);
|
|
108
|
+
if (scope === "all" || scope === "client")
|
|
109
|
+
rm(CLIENT_FILE);
|
|
110
|
+
if (scope === "all" || scope === "discovery")
|
|
111
|
+
rm(DISCOVERY_FILE);
|
|
112
|
+
if (scope === "all" || scope === "verifier") {
|
|
113
|
+
this.codeVerifierValue = undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export function removeAllOAuthFiles() {
|
|
118
|
+
const base = getTymioConfigDir();
|
|
119
|
+
for (const f of [CLIENT_FILE, TOKENS_FILE, DISCOVERY_FILE]) {
|
|
120
|
+
try {
|
|
121
|
+
fs.unlinkSync(path.join(base, f));
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
/* ignore */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|