@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,64 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
4
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
5
|
+
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
6
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
+
import { defaultMcpUrl, defaultOAuthRedirectUrl } from "./configPaths.js";
|
|
9
|
+
import { FileOAuthProvider } from "./fileOAuthProvider.js";
|
|
10
|
+
import { getMcpServerInstructions } from "./persona.js";
|
|
11
|
+
import { writeStdioStartupHint } from "./stdioHints.js";
|
|
12
|
+
import { assertToolArgsMatchPinnedWorkspace, readPinnedWorkspaceSlugForStdio } from "./workspaceSlug.js";
|
|
13
|
+
function pkgVersion() {
|
|
14
|
+
try {
|
|
15
|
+
const raw = readFileSync(new URL("../package.json", import.meta.url), "utf8");
|
|
16
|
+
return JSON.parse(raw).version;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return "1.0.0";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const passthroughArgs = z.object({}).passthrough();
|
|
23
|
+
/**
|
|
24
|
+
* Stdio MCP server that proxies to the hosted Tymio Streamable HTTP MCP endpoint with OAuth tokens on disk.
|
|
25
|
+
*/
|
|
26
|
+
export async function runHubOAuthStdio(mcpUrl = defaultMcpUrl()) {
|
|
27
|
+
writeStdioStartupHint("oauth");
|
|
28
|
+
const pinnedWorkspaceSlug = readPinnedWorkspaceSlugForStdio();
|
|
29
|
+
const redirectUrl = defaultOAuthRedirectUrl();
|
|
30
|
+
const provider = new FileOAuthProvider(redirectUrl);
|
|
31
|
+
const transport = new StreamableHTTPClientTransport(mcpUrl, { authProvider: provider });
|
|
32
|
+
const client = new Client({ name: "@tymio/mcp-server", version: pkgVersion() }, { capabilities: {} });
|
|
33
|
+
try {
|
|
34
|
+
await client.connect(transport);
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
if (e instanceof UnauthorizedError) {
|
|
38
|
+
process.stderr.write("Tymio MCP: not signed in or session expired.\n Run: tymio-mcp login (there is no MCP API key in Tymio user Settings)\n Or: tymio-mcp instructions (full setup for agents)\n API-key mode: set DRD_API_KEY (server deployment secret), not a UI setting.\n");
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
throw e;
|
|
42
|
+
}
|
|
43
|
+
const { tools } = await client.listTools();
|
|
44
|
+
const server = new McpServer({ name: "tymio-hub", version: pkgVersion() }, { instructions: getMcpServerInstructions() });
|
|
45
|
+
for (const tool of tools) {
|
|
46
|
+
const name = tool.name;
|
|
47
|
+
server.registerTool(name, {
|
|
48
|
+
title: tool.title,
|
|
49
|
+
description: tool.description ?? "",
|
|
50
|
+
inputSchema: passthroughArgs
|
|
51
|
+
}, async (args) => {
|
|
52
|
+
if (pinnedWorkspaceSlug) {
|
|
53
|
+
assertToolArgsMatchPinnedWorkspace(args ?? {}, pinnedWorkspaceSlug, name);
|
|
54
|
+
}
|
|
55
|
+
const result = await client.callTool({
|
|
56
|
+
name,
|
|
57
|
+
arguments: args ?? {}
|
|
58
|
+
});
|
|
59
|
+
return result;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
const stdio = new StdioServerTransport();
|
|
63
|
+
await server.connect(stdio);
|
|
64
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,283 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* Set DRD_API_BASE_URL and DRD_API_KEY in the environment.
|
|
5
|
-
*/
|
|
6
|
-
import { z } from "zod";
|
|
7
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
9
|
-
import { drdFetch, drdFetchText, getBaseUrl, hasApiKey } from "./api.js";
|
|
10
|
-
import { toolTextWithFeedback } from "./mcpFeedbackFooter.js";
|
|
11
|
-
const server = new McpServer({
|
|
12
|
-
name: "tymio-hub",
|
|
13
|
-
version: "1.0.0"
|
|
14
|
-
});
|
|
15
|
-
async function textContent(text) {
|
|
16
|
-
return toolTextWithFeedback(getBaseUrl(), text);
|
|
17
|
-
}
|
|
18
|
-
// --- Health & meta (no auth required for health)
|
|
19
|
-
server.registerTool("drd_health", {
|
|
20
|
-
title: "Tymio API health check",
|
|
21
|
-
description: "Check if the Tymio hub API is reachable.",
|
|
22
|
-
inputSchema: z.object({})
|
|
23
|
-
}, async () => {
|
|
24
|
-
const data = await drdFetch("/api/health");
|
|
25
|
-
return textContent(JSON.stringify(data));
|
|
26
|
-
});
|
|
27
|
-
server.registerTool("drd_meta", {
|
|
28
|
-
title: "Get Tymio meta",
|
|
29
|
-
description: "Get meta data: domains, products, accounts, partners, personas, revenue streams, users.",
|
|
30
|
-
inputSchema: z.object({})
|
|
31
|
-
}, async () => {
|
|
32
|
-
const data = await drdFetch("/api/meta");
|
|
33
|
-
return textContent(JSON.stringify(data, null, 2));
|
|
34
|
-
});
|
|
35
|
-
// --- Initiatives
|
|
36
|
-
const listInitiativesSchema = z.object({
|
|
37
|
-
domainId: z.string().optional(),
|
|
38
|
-
ownerId: z.string().optional(),
|
|
39
|
-
horizon: z.enum(["NOW", "NEXT", "LATER"]).optional(),
|
|
40
|
-
priority: z.enum(["P0", "P1", "P2", "P3"]).optional(),
|
|
41
|
-
isGap: z.boolean().optional()
|
|
42
|
-
});
|
|
43
|
-
server.registerTool("drd_list_initiatives", {
|
|
44
|
-
title: "List initiatives",
|
|
45
|
-
description: "List initiatives with optional filters: domainId, ownerId, horizon, priority, isGap.",
|
|
46
|
-
inputSchema: listInitiativesSchema
|
|
47
|
-
}, async (args) => {
|
|
48
|
-
const params = new URLSearchParams();
|
|
49
|
-
if (args.domainId)
|
|
50
|
-
params.set("domainId", args.domainId);
|
|
51
|
-
if (args.ownerId)
|
|
52
|
-
params.set("ownerId", args.ownerId);
|
|
53
|
-
if (args.horizon)
|
|
54
|
-
params.set("horizon", args.horizon);
|
|
55
|
-
if (args.priority)
|
|
56
|
-
params.set("priority", args.priority);
|
|
57
|
-
if (args.isGap !== undefined)
|
|
58
|
-
params.set("isGap", String(args.isGap));
|
|
59
|
-
const data = await drdFetch(`/api/initiatives?${params.toString()}`);
|
|
60
|
-
return textContent(JSON.stringify(data.initiatives, null, 2));
|
|
61
|
-
});
|
|
62
|
-
server.registerTool("drd_get_initiative", {
|
|
63
|
-
title: "Get initiative by ID",
|
|
64
|
-
description: "Get a single initiative by its ID.",
|
|
65
|
-
inputSchema: z.object({ id: z.string().describe("Initiative ID") })
|
|
66
|
-
}, async ({ id }) => {
|
|
67
|
-
const data = await drdFetch(`/api/initiatives/${id}`);
|
|
68
|
-
return textContent(JSON.stringify(data.initiative, null, 2));
|
|
69
|
-
});
|
|
70
|
-
server.registerTool("drd_create_initiative", {
|
|
71
|
-
title: "Create initiative",
|
|
72
|
-
description: "Create a new initiative. Requires admin/editor role.",
|
|
73
|
-
inputSchema: z.object({
|
|
74
|
-
title: z.string(),
|
|
75
|
-
domainId: z.string(),
|
|
76
|
-
description: z.string().optional(),
|
|
77
|
-
ownerId: z.string().optional(),
|
|
78
|
-
priority: z.enum(["P0", "P1", "P2", "P3"]).optional(),
|
|
79
|
-
horizon: z.enum(["NOW", "NEXT", "LATER"]).optional(),
|
|
80
|
-
status: z.enum(["IDEA", "PLANNED", "IN_PROGRESS", "DONE", "BLOCKED"]).optional(),
|
|
81
|
-
commercialType: z.string().optional(),
|
|
82
|
-
isGap: z.boolean().optional()
|
|
83
|
-
})
|
|
84
|
-
}, async (body) => {
|
|
85
|
-
const data = await drdFetch("/api/initiatives", {
|
|
86
|
-
method: "POST",
|
|
87
|
-
body: JSON.stringify(body)
|
|
88
|
-
});
|
|
89
|
-
return textContent(JSON.stringify(data.initiative, null, 2));
|
|
90
|
-
});
|
|
91
|
-
server.registerTool("drd_update_initiative", {
|
|
92
|
-
title: "Update initiative",
|
|
93
|
-
description: "Update an existing initiative by ID.",
|
|
94
|
-
inputSchema: z.object({
|
|
95
|
-
id: z.string(),
|
|
96
|
-
title: z.string().optional(),
|
|
97
|
-
domainId: z.string().optional(),
|
|
98
|
-
description: z.string().optional(),
|
|
99
|
-
ownerId: z.string().optional(),
|
|
100
|
-
priority: z.enum(["P0", "P1", "P2", "P3"]).optional(),
|
|
101
|
-
horizon: z.enum(["NOW", "NEXT", "LATER"]).optional(),
|
|
102
|
-
status: z.enum(["IDEA", "PLANNED", "IN_PROGRESS", "DONE", "BLOCKED"]).optional(),
|
|
103
|
-
commercialType: z.string().optional(),
|
|
104
|
-
isGap: z.boolean().optional()
|
|
105
|
-
})
|
|
106
|
-
}, async ({ id, ...body }) => {
|
|
107
|
-
const data = await drdFetch(`/api/initiatives/${id}`, {
|
|
108
|
-
method: "PUT",
|
|
109
|
-
body: JSON.stringify(body)
|
|
110
|
-
});
|
|
111
|
-
return textContent(JSON.stringify(data.initiative, null, 2));
|
|
112
|
-
});
|
|
113
|
-
server.registerTool("drd_delete_initiative", {
|
|
114
|
-
title: "Delete initiative",
|
|
115
|
-
description: "Delete an initiative by ID.",
|
|
116
|
-
inputSchema: z.object({ id: z.string() })
|
|
117
|
-
}, async ({ id }) => {
|
|
118
|
-
await drdFetch(`/api/initiatives/${id}`, { method: "DELETE" });
|
|
119
|
-
return textContent(JSON.stringify({ ok: true }));
|
|
120
|
-
});
|
|
121
|
-
// --- Domains, products, personas
|
|
122
|
-
server.registerTool("drd_list_domains", {
|
|
123
|
-
title: "List domains",
|
|
124
|
-
description: "List all domains.",
|
|
125
|
-
inputSchema: z.object({})
|
|
126
|
-
}, async () => {
|
|
127
|
-
const data = await drdFetch("/api/domains");
|
|
128
|
-
return textContent(JSON.stringify(data.domains, null, 2));
|
|
129
|
-
});
|
|
130
|
-
server.registerTool("drd_create_domain", {
|
|
131
|
-
title: "Create domain",
|
|
132
|
-
description: "Create a new domain (pillar). Requires workspace OWNER or ADMIN.",
|
|
133
|
-
inputSchema: z.object({
|
|
134
|
-
name: z.string().min(1),
|
|
135
|
-
color: z.string().min(1),
|
|
136
|
-
sortOrder: z.number().int().optional()
|
|
137
|
-
})
|
|
138
|
-
}, async (body) => {
|
|
139
|
-
const data = await drdFetch("/api/domains", {
|
|
140
|
-
method: "POST",
|
|
141
|
-
body: JSON.stringify({
|
|
142
|
-
name: body.name,
|
|
143
|
-
color: body.color,
|
|
144
|
-
sortOrder: body.sortOrder ?? 0
|
|
145
|
-
})
|
|
146
|
-
});
|
|
147
|
-
return textContent(JSON.stringify(data.domain, null, 2));
|
|
148
|
-
});
|
|
149
|
-
server.registerTool("drd_list_products", {
|
|
150
|
-
title: "List products",
|
|
151
|
-
description: "List all products (with hierarchy).",
|
|
152
|
-
inputSchema: z.object({})
|
|
153
|
-
}, async () => {
|
|
154
|
-
const data = await drdFetch("/api/products");
|
|
155
|
-
return textContent(JSON.stringify(data.products, null, 2));
|
|
156
|
-
});
|
|
157
|
-
server.registerTool("drd_list_personas", {
|
|
158
|
-
title: "List personas",
|
|
159
|
-
description: "List all personas.",
|
|
160
|
-
inputSchema: z.object({})
|
|
161
|
-
}, async () => {
|
|
162
|
-
const data = await drdFetch("/api/personas");
|
|
163
|
-
return textContent(JSON.stringify(data.personas, null, 2));
|
|
164
|
-
});
|
|
165
|
-
server.registerTool("drd_list_accounts", {
|
|
166
|
-
title: "List accounts",
|
|
167
|
-
description: "List all accounts.",
|
|
168
|
-
inputSchema: z.object({})
|
|
169
|
-
}, async () => {
|
|
170
|
-
const data = await drdFetch("/api/accounts");
|
|
171
|
-
return textContent(JSON.stringify(data.accounts, null, 2));
|
|
172
|
-
});
|
|
173
|
-
server.registerTool("drd_list_partners", {
|
|
174
|
-
title: "List partners",
|
|
175
|
-
description: "List all partners.",
|
|
176
|
-
inputSchema: z.object({})
|
|
177
|
-
}, async () => {
|
|
178
|
-
const data = await drdFetch("/api/partners");
|
|
179
|
-
return textContent(JSON.stringify(data.partners, null, 2));
|
|
180
|
-
});
|
|
181
|
-
// --- KPIs, milestones, stakeholders
|
|
182
|
-
server.registerTool("drd_list_kpis", {
|
|
183
|
-
title: "List KPIs",
|
|
184
|
-
description: "List all initiative KPIs with their initiative context (title, domain, owner).",
|
|
185
|
-
inputSchema: z.object({})
|
|
186
|
-
}, async () => {
|
|
187
|
-
const data = await drdFetch("/api/kpis");
|
|
188
|
-
return textContent(JSON.stringify(data.kpis, null, 2));
|
|
189
|
-
});
|
|
190
|
-
server.registerTool("drd_list_milestones", {
|
|
191
|
-
title: "List milestones",
|
|
192
|
-
description: "List all initiative milestones with their initiative context.",
|
|
193
|
-
inputSchema: z.object({})
|
|
194
|
-
}, async () => {
|
|
195
|
-
const data = await drdFetch("/api/milestones");
|
|
196
|
-
return textContent(JSON.stringify(data.milestones, null, 2));
|
|
197
|
-
});
|
|
198
|
-
server.registerTool("drd_list_demands", {
|
|
199
|
-
title: "List demands",
|
|
200
|
-
description: "List all demands (from accounts, partners, internal, compliance).",
|
|
201
|
-
inputSchema: z.object({})
|
|
202
|
-
}, async () => {
|
|
203
|
-
const data = await drdFetch("/api/demands");
|
|
204
|
-
return textContent(JSON.stringify(data.demands, null, 2));
|
|
205
|
-
});
|
|
206
|
-
server.registerTool("drd_list_revenue_streams", {
|
|
207
|
-
title: "List revenue streams",
|
|
208
|
-
description: "List all revenue streams.",
|
|
209
|
-
inputSchema: z.object({})
|
|
210
|
-
}, async () => {
|
|
211
|
-
const data = await drdFetch("/api/revenue-streams");
|
|
212
|
-
return textContent(JSON.stringify(data.revenueStreams, null, 2));
|
|
213
|
-
});
|
|
214
|
-
server.registerTool("tymio_get_coding_agent_guide", {
|
|
215
|
-
title: "Get Tymio coding agent playbook (Markdown)",
|
|
216
|
-
description: "Full docs/CODING_AGENT_TYMIO.md: MCP usage, as-is to Tymio, feature lifecycle. Call at session start when automating this hub.",
|
|
217
|
-
inputSchema: z.object({})
|
|
218
|
-
}, async () => {
|
|
219
|
-
const md = await drdFetchText("/api/agent/coding-guide");
|
|
220
|
-
return textContent(md);
|
|
221
|
-
});
|
|
222
|
-
server.registerTool("tymio_get_agent_brief", {
|
|
223
|
-
title: "Get compiled agent capability brief",
|
|
224
|
-
description: "Returns the hub capability ontology as Markdown or JSON. mode=compact|full, format=md|json.",
|
|
225
|
-
inputSchema: z.object({
|
|
226
|
-
mode: z.enum(["compact", "full"]).default("compact"),
|
|
227
|
-
format: z.enum(["md", "json"]).default("md")
|
|
228
|
-
})
|
|
229
|
-
}, async (args) => {
|
|
230
|
-
const params = new URLSearchParams({ mode: args.mode, format: args.format });
|
|
231
|
-
const q = params.toString();
|
|
232
|
-
if (args.format === "md") {
|
|
233
|
-
const text = await drdFetchText(`/api/ontology/brief?${q}`);
|
|
234
|
-
return textContent(text);
|
|
235
|
-
}
|
|
236
|
-
const raw = await drdFetchText(`/api/ontology/brief?${q}`);
|
|
237
|
-
try {
|
|
238
|
-
const parsed = JSON.parse(raw);
|
|
239
|
-
return textContent(JSON.stringify(parsed, null, 2));
|
|
240
|
-
}
|
|
241
|
-
catch {
|
|
242
|
-
return textContent(raw);
|
|
243
|
-
}
|
|
244
|
-
});
|
|
245
|
-
server.registerTool("tymio_list_capabilities", {
|
|
246
|
-
title: "List hub capabilities (ontology)",
|
|
247
|
-
description: "Optional status: ACTIVE, DRAFT, DEPRECATED.",
|
|
248
|
-
inputSchema: z.object({ status: z.enum(["ACTIVE", "DRAFT", "DEPRECATED"]).optional() })
|
|
249
|
-
}, async (args) => {
|
|
250
|
-
const params = new URLSearchParams();
|
|
251
|
-
if (args.status)
|
|
252
|
-
params.set("status", args.status);
|
|
253
|
-
const q = params.toString();
|
|
254
|
-
const data = await drdFetch(`/api/ontology/capabilities${q ? `?${q}` : ""}`);
|
|
255
|
-
return textContent(JSON.stringify(data, null, 2));
|
|
256
|
-
});
|
|
257
|
-
server.registerTool("tymio_get_capability", {
|
|
258
|
-
title: "Get one capability by id or slug",
|
|
259
|
-
description: "Provide id or slug.",
|
|
260
|
-
inputSchema: z.object({ id: z.string().optional(), slug: z.string().optional() })
|
|
261
|
-
}, async (args) => {
|
|
262
|
-
if (args.id) {
|
|
263
|
-
const data = await drdFetch(`/api/ontology/capabilities/${args.id}`);
|
|
264
|
-
return textContent(JSON.stringify(data, null, 2));
|
|
265
|
-
}
|
|
266
|
-
if (args.slug) {
|
|
267
|
-
const data = await drdFetch(`/api/ontology/capabilities/by-slug/${encodeURIComponent(args.slug)}`);
|
|
268
|
-
return textContent(JSON.stringify(data, null, 2));
|
|
269
|
-
}
|
|
270
|
-
throw new Error("Provide id or slug");
|
|
271
|
-
});
|
|
272
|
-
// --- Run
|
|
273
|
-
async function main() {
|
|
274
|
-
if (!hasApiKey()) {
|
|
275
|
-
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");
|
|
276
|
-
}
|
|
277
|
-
const transport = new StdioServerTransport();
|
|
278
|
-
await server.connect(transport);
|
|
279
|
-
}
|
|
280
|
-
main().catch((err) => {
|
|
2
|
+
import { runCli } from "./cli.js";
|
|
3
|
+
runCli(process.argv).catch((err) => {
|
|
281
4
|
console.error(err);
|
|
282
5
|
process.exit(1);
|
|
283
6
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runLoginCommand(mcpUrl?: URL): Promise<void>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
2
|
+
import { defaultMcpUrl, defaultOAuthRedirectUrl } from "./configPaths.js";
|
|
3
|
+
import { FileOAuthProvider } from "./fileOAuthProvider.js";
|
|
4
|
+
import { startOAuthCallbackServer } from "./oauthCallbackServer.js";
|
|
5
|
+
export async function runLoginCommand(mcpUrl = defaultMcpUrl()) {
|
|
6
|
+
const redirectUrl = defaultOAuthRedirectUrl();
|
|
7
|
+
const provider = new FileOAuthProvider(redirectUrl);
|
|
8
|
+
const { waitForCode, close } = await startOAuthCallbackServer(redirectUrl, provider);
|
|
9
|
+
try {
|
|
10
|
+
let result = await auth(provider, { serverUrl: mcpUrl });
|
|
11
|
+
if (result === "REDIRECT") {
|
|
12
|
+
const code = await waitForCode;
|
|
13
|
+
result = await auth(provider, { serverUrl: mcpUrl, authorizationCode: code });
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
void waitForCode.catch(() => {
|
|
17
|
+
/* already authorized; browser callback not used */
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
if (result !== "AUTHORIZED") {
|
|
21
|
+
throw new Error(`Unexpected auth result: ${result}`);
|
|
22
|
+
}
|
|
23
|
+
provider.clearLoginSession();
|
|
24
|
+
process.stderr.write("Tymio MCP login succeeded. You can run your editor MCP client (tymio-mcp).\n");
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
close();
|
|
28
|
+
provider.clearLoginSession();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FileOAuthProvider } from "./fileOAuthProvider.js";
|
|
2
|
+
export interface OAuthCallbackHandle {
|
|
3
|
+
waitForCode: Promise<string>;
|
|
4
|
+
close: () => void;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Listens on the host/port of `redirectUrl` and resolves with the authorization `code`
|
|
8
|
+
* once the browser hits the redirect URI. Resolves after the socket is accepting connections.
|
|
9
|
+
*/
|
|
10
|
+
export declare function startOAuthCallbackServer(redirectUrl: URL, provider: FileOAuthProvider): Promise<OAuthCallbackHandle>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
/**
|
|
3
|
+
* Listens on the host/port of `redirectUrl` and resolves with the authorization `code`
|
|
4
|
+
* once the browser hits the redirect URI. Resolves after the socket is accepting connections.
|
|
5
|
+
*/
|
|
6
|
+
export async function startOAuthCallbackServer(redirectUrl, provider) {
|
|
7
|
+
const pathname = redirectUrl.pathname || "/";
|
|
8
|
+
let resolveCode;
|
|
9
|
+
let rejectWait;
|
|
10
|
+
const waitForCode = new Promise((resolve, reject) => {
|
|
11
|
+
resolveCode = resolve;
|
|
12
|
+
rejectWait = reject;
|
|
13
|
+
});
|
|
14
|
+
const server = http.createServer(async (req, res) => {
|
|
15
|
+
try {
|
|
16
|
+
if (!req.url) {
|
|
17
|
+
res.writeHead(400);
|
|
18
|
+
res.end("Bad request");
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const u = new URL(req.url, `http://${req.headers.host ?? "127.0.0.1"}`);
|
|
22
|
+
if (u.pathname !== pathname) {
|
|
23
|
+
res.writeHead(404);
|
|
24
|
+
res.end("Not found");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const code = u.searchParams.get("code");
|
|
28
|
+
const state = u.searchParams.get("state");
|
|
29
|
+
const errParam = u.searchParams.get("error");
|
|
30
|
+
if (errParam) {
|
|
31
|
+
const desc = u.searchParams.get("error_description") ?? errParam;
|
|
32
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
33
|
+
res.end(`<p>Authorization failed: ${escapeHtml(desc)}</p>`);
|
|
34
|
+
rejectWait(new Error(desc));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (!code || !state) {
|
|
38
|
+
res.writeHead(400);
|
|
39
|
+
res.end("Missing code or state");
|
|
40
|
+
rejectWait(new Error("Missing authorization code"));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const expected = await provider.state();
|
|
44
|
+
if (state !== expected) {
|
|
45
|
+
res.writeHead(400);
|
|
46
|
+
res.end("Invalid state");
|
|
47
|
+
rejectWait(new Error("OAuth state mismatch"));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
51
|
+
res.end("<p>Signed in to Tymio. You can close this tab and return to the terminal.</p>");
|
|
52
|
+
resolveCode(code);
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
res.writeHead(500);
|
|
56
|
+
res.end("Internal error");
|
|
57
|
+
rejectWait(e instanceof Error ? e : new Error(String(e)));
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
const host = redirectUrl.hostname;
|
|
61
|
+
const port = Number(redirectUrl.port) || (redirectUrl.protocol === "https:" ? 443 : 80);
|
|
62
|
+
const listening = new Promise((resolve, reject) => {
|
|
63
|
+
server.once("error", reject);
|
|
64
|
+
server.listen(port, host, () => {
|
|
65
|
+
process.stderr.write(`Listening for OAuth callback on ${redirectUrl.origin}${pathname}\n`);
|
|
66
|
+
server.off("error", reject);
|
|
67
|
+
resolve();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
try {
|
|
71
|
+
await listening;
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
server.close();
|
|
75
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
76
|
+
}
|
|
77
|
+
server.on("error", (err) => {
|
|
78
|
+
rejectWait(err instanceof Error ? err : new Error(String(err)));
|
|
79
|
+
});
|
|
80
|
+
return {
|
|
81
|
+
waitForCode,
|
|
82
|
+
close: () => {
|
|
83
|
+
server.close();
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function escapeHtml(s) {
|
|
88
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
89
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
declare const PERSONA_IDS: readonly ["pm", "po", "dev", "workspace"];
|
|
2
|
+
export type PersonaId = (typeof PERSONA_IDS)[number];
|
|
3
|
+
/** `hub` is an alias for `workspace` (base hub agent behavior). */
|
|
4
|
+
export declare function normalizePersonaId(raw: string | undefined): PersonaId | null;
|
|
5
|
+
export declare function listPersonaIds(): readonly PersonaId[];
|
|
6
|
+
export declare function loadPersonaMarkdown(id: PersonaId): string;
|
|
7
|
+
export declare function personaListHelpText(): string;
|
|
8
|
+
/**
|
|
9
|
+
* Full MCP server `instructions`: base CLI guide plus optional persona from `TYMIO_MCP_PERSONA`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function getMcpServerInstructions(): string;
|
|
12
|
+
/** Non-empty when a valid persona is active (for stderr startup hint). */
|
|
13
|
+
export declare function activePersonaForHint(): PersonaId | null;
|
|
14
|
+
/** CLI subcommand: `tymio-mcp persona …` — exit code. */
|
|
15
|
+
export declare function runPersonaCli(argv: string[]): number;
|
|
16
|
+
export {};
|
package/dist/persona.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { AGENT_INSTRUCTIONS } from "./cliMessages.js";
|
|
5
|
+
const PERSONA_IDS = ["pm", "po", "dev", "workspace"];
|
|
6
|
+
function personasDir() {
|
|
7
|
+
return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "personas");
|
|
8
|
+
}
|
|
9
|
+
/** `hub` is an alias for `workspace` (base hub agent behavior). */
|
|
10
|
+
export function normalizePersonaId(raw) {
|
|
11
|
+
if (!raw?.trim())
|
|
12
|
+
return null;
|
|
13
|
+
const s = raw.trim().toLowerCase();
|
|
14
|
+
if (s === "hub")
|
|
15
|
+
return "workspace";
|
|
16
|
+
if (PERSONA_IDS.includes(s))
|
|
17
|
+
return s;
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
export function listPersonaIds() {
|
|
21
|
+
return PERSONA_IDS;
|
|
22
|
+
}
|
|
23
|
+
export function loadPersonaMarkdown(id) {
|
|
24
|
+
const file = path.join(personasDir(), `${id}.md`);
|
|
25
|
+
if (!existsSync(file)) {
|
|
26
|
+
throw new Error(`Bundled persona file missing: ${file}`);
|
|
27
|
+
}
|
|
28
|
+
return readFileSync(file, "utf8");
|
|
29
|
+
}
|
|
30
|
+
export function personaListHelpText() {
|
|
31
|
+
return `Tymio MCP — bundled agent personas (Markdown prompts)
|
|
32
|
+
|
|
33
|
+
Usage:
|
|
34
|
+
tymio-mcp persona list Show this list
|
|
35
|
+
tymio-mcp persona <id> Print persona prompt to stdout (pipe into your agent / docs)
|
|
36
|
+
|
|
37
|
+
Ids: ${PERSONA_IDS.join(", ")}
|
|
38
|
+
|
|
39
|
+
Embed in MCP sessions (stdio / some clients read server instructions):
|
|
40
|
+
export TYMIO_MCP_PERSONA=pm # or po, dev, workspace
|
|
41
|
+
tymio-mcp # persona text is appended to MCP server instructions
|
|
42
|
+
|
|
43
|
+
Cursor: you can instead enable Skills (.cursor/skills/tymio-*-agent); CLI personas help IDEs without Skills or CI.
|
|
44
|
+
|
|
45
|
+
`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Full MCP server `instructions`: base CLI guide plus optional persona from `TYMIO_MCP_PERSONA`.
|
|
49
|
+
*/
|
|
50
|
+
export function getMcpServerInstructions() {
|
|
51
|
+
const raw = process.env.TYMIO_MCP_PERSONA;
|
|
52
|
+
if (!raw?.trim())
|
|
53
|
+
return AGENT_INSTRUCTIONS;
|
|
54
|
+
const id = normalizePersonaId(raw);
|
|
55
|
+
if (!id) {
|
|
56
|
+
process.stderr.write(`[tymio-mcp] Unknown TYMIO_MCP_PERSONA="${raw.trim()}". Valid: ${PERSONA_IDS.join(", ")} (hub aliases workspace). See: tymio-mcp persona list\n`);
|
|
57
|
+
return AGENT_INSTRUCTIONS;
|
|
58
|
+
}
|
|
59
|
+
let block;
|
|
60
|
+
try {
|
|
61
|
+
block = loadPersonaMarkdown(id);
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
process.stderr.write(`[tymio-mcp] Could not load persona "${id}": ${e}\n`);
|
|
65
|
+
return AGENT_INSTRUCTIONS;
|
|
66
|
+
}
|
|
67
|
+
return `${AGENT_INSTRUCTIONS}\n\n---\n\n## Bundled agent persona (\`TYMIO_MCP_PERSONA=${id}\`)\n\n${block}\n`;
|
|
68
|
+
}
|
|
69
|
+
/** Non-empty when a valid persona is active (for stderr startup hint). */
|
|
70
|
+
export function activePersonaForHint() {
|
|
71
|
+
return normalizePersonaId(process.env.TYMIO_MCP_PERSONA);
|
|
72
|
+
}
|
|
73
|
+
/** CLI subcommand: `tymio-mcp persona …` — exit code. */
|
|
74
|
+
export function runPersonaCli(argv) {
|
|
75
|
+
const sub = argv[0];
|
|
76
|
+
if (!sub || sub === "list" || sub === "--help" || sub === "-h") {
|
|
77
|
+
process.stderr.write(personaListHelpText());
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
const id = normalizePersonaId(sub);
|
|
81
|
+
if (!id) {
|
|
82
|
+
process.stderr.write(`Unknown persona "${sub}". Run: tymio-mcp persona list\n`);
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
process.stdout.write(loadPersonaMarkdown(id));
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
process.stderr.write(String(e) + "\n");
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { activePersonaForHint } from "./persona.js";
|
|
2
|
+
/**
|
|
3
|
+
* One-line stderr hint when starting stdio (does not touch stdout — MCP JSON-RPC stays clean).
|
|
4
|
+
* Suppress with TYMIO_MCP_QUIET=1 or non-TTY stderr.
|
|
5
|
+
*/
|
|
6
|
+
export function writeStdioStartupHint(mode) {
|
|
7
|
+
if (process.env.TYMIO_MCP_QUIET)
|
|
8
|
+
return;
|
|
9
|
+
if (!process.stderr.isTTY)
|
|
10
|
+
return;
|
|
11
|
+
if (mode === "oauth") {
|
|
12
|
+
process.stderr.write("[tymio-mcp] OAuth proxy to Tymio MCP. No MCP key in Tymio Settings — use login/OAuth. First run: `tymio-mcp login`. Set TYMIO_WORKSPACE_SLUG (or DRD_WORKSPACE_SLUG) to pin this server to one workspace. Guide: `tymio-mcp instructions` | `tymio-mcp help`\n");
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
process.stderr.write("[tymio-mcp] API-key REST bridge. Set DRD_API_BASE_URL + DRD_API_KEY + TYMIO_WORKSPACE_SLUG (tenant resolved to X-Tenant-Id). Agent guide: `tymio-mcp instructions`\n");
|
|
16
|
+
}
|
|
17
|
+
const persona = activePersonaForHint();
|
|
18
|
+
if (persona) {
|
|
19
|
+
process.stderr.write(`[tymio-mcp] TYMIO_MCP_PERSONA=${persona} — persona text is appended to MCP server instructions. Print prompt: tymio-mcp persona ${persona}\n`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Matches hub workspace slug rules (see server tenant slug validation). */
|
|
3
|
+
export declare const WORKSPACE_SLUG_ZOD: z.ZodString;
|
|
4
|
+
export declare function isValidWorkspaceSlugFormat(slug: string): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* Pinned slug for this stdio process: every proxied MCP tool call must use this workspace.
|
|
7
|
+
* Set `TYMIO_MCP_SKIP_WORKSPACE_PINNING=1` only in tests.
|
|
8
|
+
*/
|
|
9
|
+
export declare function readPinnedWorkspaceSlugForStdio(): string | null;
|
|
10
|
+
/** Enforce agent-supplied slug matches pinned CLI config (defense in depth vs hub session). */
|
|
11
|
+
export declare function assertToolArgsMatchPinnedWorkspace(args: unknown, pinnedSlug: string, toolName: string): void;
|
|
12
|
+
/** After assert, remove workspaceSlug before REST bodies. */
|
|
13
|
+
export declare function omitWorkspaceSlug<T extends Record<string, unknown>>(args: T): Omit<T, "workspaceSlug">;
|