@loopops/mcp-server 3.41.0 → 3.43.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/dist/index.js +20 -0
- package/dist/skills/handlers.d.ts +17 -0
- package/dist/skills/handlers.js +67 -0
- package/dist/skills/loader.d.ts +194 -0
- package/dist/skills/loader.js +93 -0
- package/dist/skills/loader.test.d.ts +1 -0
- package/dist/skills/loader.test.js +159 -0
- package/dist/tools/cpq.d.ts +13 -0
- package/dist/tools/cpq.js +18 -0
- package/dist/tools/reporting.js +38 -1
- package/package.json +8 -7
package/dist/index.js
CHANGED
|
@@ -17,8 +17,11 @@ import { registerPursueTools } from "./tools/pursue.js";
|
|
|
17
17
|
import { registerAccountMasterTools } from "./tools/account-master.js";
|
|
18
18
|
import { registerPeopleMasterTools } from "./tools/people-master.js";
|
|
19
19
|
import { registerScoringTools } from "./tools/scoring.js";
|
|
20
|
+
import { registerCpqTools } from "./tools/cpq.js";
|
|
20
21
|
import { registerSfdcSyncTools } from "./tools/sfdc-sync.js";
|
|
21
22
|
import { registerTalTools } from "./tools/tal.js";
|
|
23
|
+
import { parseSkillModesResponse } from "./skills/loader.js";
|
|
24
|
+
import { registerSkillsAsPrompts } from "./skills/handlers.js";
|
|
22
25
|
// Read our own package.json at runtime so the version baked into MCP
|
|
23
26
|
// initialize-handshake `serverInfo.version` matches the published npm
|
|
24
27
|
// version. Was hardcoded "1.0.0" — confusing in Claude Desktop logs and
|
|
@@ -67,5 +70,22 @@ registerDeployTools(server, allowedSkills);
|
|
|
67
70
|
registerTalTools(server, allowedSkills);
|
|
68
71
|
registerScoringTools(server, allowedSkills);
|
|
69
72
|
registerSfdcSyncTools(server, allowedSkills);
|
|
73
|
+
registerCpqTools(server, allowedSkills);
|
|
74
|
+
// Skills framework — fetch role-visible skills from the Loop API and
|
|
75
|
+
// register each as a server-provided MCP prompt. Surfaces in the
|
|
76
|
+
// client's `/` menu and bundles (system prompt + tool emphasis +
|
|
77
|
+
// on-load context). See docs/engineering/mcp-skills.md.
|
|
78
|
+
//
|
|
79
|
+
// Failure here is non-fatal: tools still work; the user just won't see
|
|
80
|
+
// skills in the `/` menu until they reconnect.
|
|
81
|
+
try {
|
|
82
|
+
const raw = await trpcQuery("mcp.getMySkillModes");
|
|
83
|
+
const skillModes = parseSkillModesResponse(raw);
|
|
84
|
+
registerSkillsAsPrompts(server, skillModes);
|
|
85
|
+
console.error(`[MCP] Skills registered: ${skillModes.map((s) => s.name).join(", ") || "(none for this role)"}`);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
console.error("[MCP] Failed to load skill modes — continuing without skill prompts:", err instanceof Error ? err.message : String(err));
|
|
89
|
+
}
|
|
70
90
|
const transport = new StdioServerTransport();
|
|
71
91
|
await server.connect(transport);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { Skill } from "./loader.js";
|
|
3
|
+
/**
|
|
4
|
+
* Wire each skill into the MCP server as a server-provided prompt.
|
|
5
|
+
*
|
|
6
|
+
* Clients (Claude Desktop, Claude Code, Codex CLI) discover these via
|
|
7
|
+
* the MCP `prompts/list` request and render them in their `/` menu.
|
|
8
|
+
* When the user picks a skill, the client calls `prompts/get` and the
|
|
9
|
+
* server returns the system prompt + tool hints below.
|
|
10
|
+
*
|
|
11
|
+
* Note: this registers prompts at server startup. If skills.yaml changes
|
|
12
|
+
* during a session, the user must reconnect to pick up the change. A
|
|
13
|
+
* future `prompts/list_changed` notification could make this hot-reload,
|
|
14
|
+
* but the cost of that complexity isn't worth it yet — skill changes
|
|
15
|
+
* are infrequent.
|
|
16
|
+
*/
|
|
17
|
+
export declare function registerSkillsAsPrompts(server: McpServer, skills: Skill[]): void;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire each skill into the MCP server as a server-provided prompt.
|
|
3
|
+
*
|
|
4
|
+
* Clients (Claude Desktop, Claude Code, Codex CLI) discover these via
|
|
5
|
+
* the MCP `prompts/list` request and render them in their `/` menu.
|
|
6
|
+
* When the user picks a skill, the client calls `prompts/get` and the
|
|
7
|
+
* server returns the system prompt + tool hints below.
|
|
8
|
+
*
|
|
9
|
+
* Note: this registers prompts at server startup. If skills.yaml changes
|
|
10
|
+
* during a session, the user must reconnect to pick up the change. A
|
|
11
|
+
* future `prompts/list_changed` notification could make this hot-reload,
|
|
12
|
+
* but the cost of that complexity isn't worth it yet — skill changes
|
|
13
|
+
* are infrequent.
|
|
14
|
+
*/
|
|
15
|
+
export function registerSkillsAsPrompts(server, skills) {
|
|
16
|
+
for (const skill of skills) {
|
|
17
|
+
server.registerPrompt(skill.name, {
|
|
18
|
+
title: skill.title,
|
|
19
|
+
description: skill.description,
|
|
20
|
+
// No prompt args today — skills load context themselves via
|
|
21
|
+
// on_load + tools_emphasized. If we add a parametric skill in
|
|
22
|
+
// the future (e.g., `quote_review` taking a quoteId), expand
|
|
23
|
+
// here.
|
|
24
|
+
}, async () => {
|
|
25
|
+
return {
|
|
26
|
+
messages: [
|
|
27
|
+
{
|
|
28
|
+
role: "user",
|
|
29
|
+
content: {
|
|
30
|
+
type: "text",
|
|
31
|
+
text: buildPromptText(skill),
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Compose the final text payload returned to the MCP client. The user
|
|
41
|
+
* receives this as the opening turn of the skill conversation.
|
|
42
|
+
*
|
|
43
|
+
* Three sections:
|
|
44
|
+
* 1. The skill's `system_prompt` verbatim
|
|
45
|
+
* 2. Tool hints (if any) — Claude reads these and prioritizes
|
|
46
|
+
* 3. On-load hints (if any) — instructs Claude to call these
|
|
47
|
+
* proactively to load context before responding
|
|
48
|
+
*/
|
|
49
|
+
function buildPromptText(skill) {
|
|
50
|
+
const sections = [skill.system_prompt.trim()];
|
|
51
|
+
if (skill.tools_emphasized.length > 0) {
|
|
52
|
+
sections.push(`\n---\n**Tools to favor for this skill** (others remain callable but these are the primary surface):\n` +
|
|
53
|
+
skill.tools_emphasized.map((t) => `- \`${t}\``).join("\n"));
|
|
54
|
+
}
|
|
55
|
+
if (skill.on_load.length > 0) {
|
|
56
|
+
sections.push(`\n---\n**On entering this skill, call these tools first** to load context:\n` +
|
|
57
|
+
skill.on_load
|
|
58
|
+
.map((c) => {
|
|
59
|
+
const argsStr = Object.keys(c.args).length === 0
|
|
60
|
+
? "(no args)"
|
|
61
|
+
: `with args ${JSON.stringify(c.args)}`;
|
|
62
|
+
return `1. \`${c.tool}\` ${argsStr}`;
|
|
63
|
+
})
|
|
64
|
+
.join("\n"));
|
|
65
|
+
}
|
|
66
|
+
return sections.join("\n");
|
|
67
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Skill schema for the Loop MCP skills framework.
|
|
4
|
+
*
|
|
5
|
+
* A skill is a named workflow mode that bundles a system prompt with a
|
|
6
|
+
* subset of available tools. Skills surface in the user's MCP client as
|
|
7
|
+
* server-provided prompts (Claude Desktop's `/` menu, Claude Code slash
|
|
8
|
+
* commands, Codex CLI prompts).
|
|
9
|
+
*
|
|
10
|
+
* Source of truth: `config/mcp/skills.yaml` in the loop repo. The Loop
|
|
11
|
+
* API exposes role-filtered skills via `mcp.getMySkillModes`; the MCP
|
|
12
|
+
* server then registers each as a prompt at connect time.
|
|
13
|
+
*
|
|
14
|
+
* See docs/engineering/mcp-skills.md for the framework + tool-vs-skill
|
|
15
|
+
* decision guide.
|
|
16
|
+
*/
|
|
17
|
+
export declare const skillSchema: z.ZodObject<{
|
|
18
|
+
/** Slug used to invoke the skill (e.g., `/cpq_coach`). Lowercase + underscores only. */
|
|
19
|
+
name: z.ZodString;
|
|
20
|
+
/** Display title shown in the client's prompt menu. */
|
|
21
|
+
title: z.ZodString;
|
|
22
|
+
/** One-line hover description in the client's prompt menu. */
|
|
23
|
+
description: z.ZodString;
|
|
24
|
+
/**
|
|
25
|
+
* The "you are…" prompt that defines the skill's voice + workflow.
|
|
26
|
+
* Multiline; treat as a markdown-friendly system prompt.
|
|
27
|
+
*/
|
|
28
|
+
system_prompt: z.ZodString;
|
|
29
|
+
/**
|
|
30
|
+
* Tools the agent should focus on for this skill. Not exclusive —
|
|
31
|
+
* other tools remain callable. A HINT to Claude; user doesn't see it.
|
|
32
|
+
*/
|
|
33
|
+
tools_emphasized: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
34
|
+
/**
|
|
35
|
+
* Optional turn-1 tool invocations. Rendered as "call these first"
|
|
36
|
+
* hints in the prompt response so Claude loads context proactively.
|
|
37
|
+
*
|
|
38
|
+
* Future: a stricter "auto-call" mode where the server pre-invokes
|
|
39
|
+
* these and returns the result inline.
|
|
40
|
+
*/
|
|
41
|
+
on_load: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
42
|
+
tool: z.ZodString;
|
|
43
|
+
args: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
44
|
+
}, "strip", z.ZodTypeAny, {
|
|
45
|
+
tool: string;
|
|
46
|
+
args: Record<string, unknown>;
|
|
47
|
+
}, {
|
|
48
|
+
tool: string;
|
|
49
|
+
args?: Record<string, unknown> | undefined;
|
|
50
|
+
}>, "many">>;
|
|
51
|
+
/**
|
|
52
|
+
* Roles that can see/invoke this skill. If omitted, all authenticated
|
|
53
|
+
* roles can see it. Same role names as the MCP role system: field /
|
|
54
|
+
* ops / dev / admin.
|
|
55
|
+
*/
|
|
56
|
+
roles: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
57
|
+
}, "strip", z.ZodTypeAny, {
|
|
58
|
+
description: string;
|
|
59
|
+
name: string;
|
|
60
|
+
title: string;
|
|
61
|
+
system_prompt: string;
|
|
62
|
+
tools_emphasized: string[];
|
|
63
|
+
on_load: {
|
|
64
|
+
tool: string;
|
|
65
|
+
args: Record<string, unknown>;
|
|
66
|
+
}[];
|
|
67
|
+
roles?: string[] | undefined;
|
|
68
|
+
}, {
|
|
69
|
+
description: string;
|
|
70
|
+
name: string;
|
|
71
|
+
title: string;
|
|
72
|
+
system_prompt: string;
|
|
73
|
+
tools_emphasized?: string[] | undefined;
|
|
74
|
+
on_load?: {
|
|
75
|
+
tool: string;
|
|
76
|
+
args?: Record<string, unknown> | undefined;
|
|
77
|
+
}[] | undefined;
|
|
78
|
+
roles?: string[] | undefined;
|
|
79
|
+
}>;
|
|
80
|
+
export type Skill = z.infer<typeof skillSchema>;
|
|
81
|
+
declare const skillsFileSchema: z.ZodObject<{
|
|
82
|
+
version: z.ZodString;
|
|
83
|
+
skills: z.ZodArray<z.ZodObject<{
|
|
84
|
+
/** Slug used to invoke the skill (e.g., `/cpq_coach`). Lowercase + underscores only. */
|
|
85
|
+
name: z.ZodString;
|
|
86
|
+
/** Display title shown in the client's prompt menu. */
|
|
87
|
+
title: z.ZodString;
|
|
88
|
+
/** One-line hover description in the client's prompt menu. */
|
|
89
|
+
description: z.ZodString;
|
|
90
|
+
/**
|
|
91
|
+
* The "you are…" prompt that defines the skill's voice + workflow.
|
|
92
|
+
* Multiline; treat as a markdown-friendly system prompt.
|
|
93
|
+
*/
|
|
94
|
+
system_prompt: z.ZodString;
|
|
95
|
+
/**
|
|
96
|
+
* Tools the agent should focus on for this skill. Not exclusive —
|
|
97
|
+
* other tools remain callable. A HINT to Claude; user doesn't see it.
|
|
98
|
+
*/
|
|
99
|
+
tools_emphasized: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
100
|
+
/**
|
|
101
|
+
* Optional turn-1 tool invocations. Rendered as "call these first"
|
|
102
|
+
* hints in the prompt response so Claude loads context proactively.
|
|
103
|
+
*
|
|
104
|
+
* Future: a stricter "auto-call" mode where the server pre-invokes
|
|
105
|
+
* these and returns the result inline.
|
|
106
|
+
*/
|
|
107
|
+
on_load: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
108
|
+
tool: z.ZodString;
|
|
109
|
+
args: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
110
|
+
}, "strip", z.ZodTypeAny, {
|
|
111
|
+
tool: string;
|
|
112
|
+
args: Record<string, unknown>;
|
|
113
|
+
}, {
|
|
114
|
+
tool: string;
|
|
115
|
+
args?: Record<string, unknown> | undefined;
|
|
116
|
+
}>, "many">>;
|
|
117
|
+
/**
|
|
118
|
+
* Roles that can see/invoke this skill. If omitted, all authenticated
|
|
119
|
+
* roles can see it. Same role names as the MCP role system: field /
|
|
120
|
+
* ops / dev / admin.
|
|
121
|
+
*/
|
|
122
|
+
roles: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
123
|
+
}, "strip", z.ZodTypeAny, {
|
|
124
|
+
description: string;
|
|
125
|
+
name: string;
|
|
126
|
+
title: string;
|
|
127
|
+
system_prompt: string;
|
|
128
|
+
tools_emphasized: string[];
|
|
129
|
+
on_load: {
|
|
130
|
+
tool: string;
|
|
131
|
+
args: Record<string, unknown>;
|
|
132
|
+
}[];
|
|
133
|
+
roles?: string[] | undefined;
|
|
134
|
+
}, {
|
|
135
|
+
description: string;
|
|
136
|
+
name: string;
|
|
137
|
+
title: string;
|
|
138
|
+
system_prompt: string;
|
|
139
|
+
tools_emphasized?: string[] | undefined;
|
|
140
|
+
on_load?: {
|
|
141
|
+
tool: string;
|
|
142
|
+
args?: Record<string, unknown> | undefined;
|
|
143
|
+
}[] | undefined;
|
|
144
|
+
roles?: string[] | undefined;
|
|
145
|
+
}>, "many">;
|
|
146
|
+
}, "strip", z.ZodTypeAny, {
|
|
147
|
+
version: string;
|
|
148
|
+
skills: {
|
|
149
|
+
description: string;
|
|
150
|
+
name: string;
|
|
151
|
+
title: string;
|
|
152
|
+
system_prompt: string;
|
|
153
|
+
tools_emphasized: string[];
|
|
154
|
+
on_load: {
|
|
155
|
+
tool: string;
|
|
156
|
+
args: Record<string, unknown>;
|
|
157
|
+
}[];
|
|
158
|
+
roles?: string[] | undefined;
|
|
159
|
+
}[];
|
|
160
|
+
}, {
|
|
161
|
+
version: string;
|
|
162
|
+
skills: {
|
|
163
|
+
description: string;
|
|
164
|
+
name: string;
|
|
165
|
+
title: string;
|
|
166
|
+
system_prompt: string;
|
|
167
|
+
tools_emphasized?: string[] | undefined;
|
|
168
|
+
on_load?: {
|
|
169
|
+
tool: string;
|
|
170
|
+
args?: Record<string, unknown> | undefined;
|
|
171
|
+
}[] | undefined;
|
|
172
|
+
roles?: string[] | undefined;
|
|
173
|
+
}[];
|
|
174
|
+
}>;
|
|
175
|
+
export type SkillsFile = z.infer<typeof skillsFileSchema>;
|
|
176
|
+
/**
|
|
177
|
+
* Load + validate `config/mcp/skills.yaml`. Used server-side (Loop API)
|
|
178
|
+
* to source the skills registry; throws on schema violation so bad
|
|
179
|
+
* config fails the build/start rather than serving a broken skill.
|
|
180
|
+
*/
|
|
181
|
+
export declare function loadSkillsYaml(yamlPath: string): SkillsFile;
|
|
182
|
+
/**
|
|
183
|
+
* Filter skills by the caller's role. Skills with no `roles` constraint
|
|
184
|
+
* are visible to everyone authenticated.
|
|
185
|
+
*/
|
|
186
|
+
export declare function skillsForRole(skills: Skill[], role: string): Skill[];
|
|
187
|
+
/**
|
|
188
|
+
* Validate an API response from `mcp.getMySkillModes`. Used client-side
|
|
189
|
+
* (the @loopops/mcp-server package) when fetching the role-filtered
|
|
190
|
+
* skills from the Loop API. Returns an empty array on shape mismatch
|
|
191
|
+
* so a broken API response doesn't crash the MCP server.
|
|
192
|
+
*/
|
|
193
|
+
export declare function parseSkillModesResponse(raw: unknown): Skill[];
|
|
194
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import yaml from "yaml";
|
|
4
|
+
/**
|
|
5
|
+
* Skill schema for the Loop MCP skills framework.
|
|
6
|
+
*
|
|
7
|
+
* A skill is a named workflow mode that bundles a system prompt with a
|
|
8
|
+
* subset of available tools. Skills surface in the user's MCP client as
|
|
9
|
+
* server-provided prompts (Claude Desktop's `/` menu, Claude Code slash
|
|
10
|
+
* commands, Codex CLI prompts).
|
|
11
|
+
*
|
|
12
|
+
* Source of truth: `config/mcp/skills.yaml` in the loop repo. The Loop
|
|
13
|
+
* API exposes role-filtered skills via `mcp.getMySkillModes`; the MCP
|
|
14
|
+
* server then registers each as a prompt at connect time.
|
|
15
|
+
*
|
|
16
|
+
* See docs/engineering/mcp-skills.md for the framework + tool-vs-skill
|
|
17
|
+
* decision guide.
|
|
18
|
+
*/
|
|
19
|
+
export const skillSchema = z.object({
|
|
20
|
+
/** Slug used to invoke the skill (e.g., `/cpq_coach`). Lowercase + underscores only. */
|
|
21
|
+
name: z
|
|
22
|
+
.string()
|
|
23
|
+
.regex(/^[a-z][a-z0-9_]*$/, "name must be snake_case and start with a letter"),
|
|
24
|
+
/** Display title shown in the client's prompt menu. */
|
|
25
|
+
title: z.string().min(1),
|
|
26
|
+
/** One-line hover description in the client's prompt menu. */
|
|
27
|
+
description: z.string().min(1),
|
|
28
|
+
/**
|
|
29
|
+
* The "you are…" prompt that defines the skill's voice + workflow.
|
|
30
|
+
* Multiline; treat as a markdown-friendly system prompt.
|
|
31
|
+
*/
|
|
32
|
+
system_prompt: z.string().min(1),
|
|
33
|
+
/**
|
|
34
|
+
* Tools the agent should focus on for this skill. Not exclusive —
|
|
35
|
+
* other tools remain callable. A HINT to Claude; user doesn't see it.
|
|
36
|
+
*/
|
|
37
|
+
tools_emphasized: z.array(z.string()).default([]),
|
|
38
|
+
/**
|
|
39
|
+
* Optional turn-1 tool invocations. Rendered as "call these first"
|
|
40
|
+
* hints in the prompt response so Claude loads context proactively.
|
|
41
|
+
*
|
|
42
|
+
* Future: a stricter "auto-call" mode where the server pre-invokes
|
|
43
|
+
* these and returns the result inline.
|
|
44
|
+
*/
|
|
45
|
+
on_load: z
|
|
46
|
+
.array(z.object({
|
|
47
|
+
tool: z.string(),
|
|
48
|
+
args: z.record(z.unknown()).default({}),
|
|
49
|
+
}))
|
|
50
|
+
.default([]),
|
|
51
|
+
/**
|
|
52
|
+
* Roles that can see/invoke this skill. If omitted, all authenticated
|
|
53
|
+
* roles can see it. Same role names as the MCP role system: field /
|
|
54
|
+
* ops / dev / admin.
|
|
55
|
+
*/
|
|
56
|
+
roles: z.array(z.string()).optional(),
|
|
57
|
+
});
|
|
58
|
+
const skillsFileSchema = z.object({
|
|
59
|
+
version: z.string(),
|
|
60
|
+
skills: z.array(skillSchema),
|
|
61
|
+
});
|
|
62
|
+
/**
|
|
63
|
+
* Load + validate `config/mcp/skills.yaml`. Used server-side (Loop API)
|
|
64
|
+
* to source the skills registry; throws on schema violation so bad
|
|
65
|
+
* config fails the build/start rather than serving a broken skill.
|
|
66
|
+
*/
|
|
67
|
+
export function loadSkillsYaml(yamlPath) {
|
|
68
|
+
const raw = readFileSync(yamlPath, "utf-8");
|
|
69
|
+
const parsed = yaml.parse(raw);
|
|
70
|
+
return skillsFileSchema.parse(parsed);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Filter skills by the caller's role. Skills with no `roles` constraint
|
|
74
|
+
* are visible to everyone authenticated.
|
|
75
|
+
*/
|
|
76
|
+
export function skillsForRole(skills, role) {
|
|
77
|
+
return skills.filter((s) => !s.roles || s.roles.includes(role));
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Validate an API response from `mcp.getMySkillModes`. Used client-side
|
|
81
|
+
* (the @loopops/mcp-server package) when fetching the role-filtered
|
|
82
|
+
* skills from the Loop API. Returns an empty array on shape mismatch
|
|
83
|
+
* so a broken API response doesn't crash the MCP server.
|
|
84
|
+
*/
|
|
85
|
+
export function parseSkillModesResponse(raw) {
|
|
86
|
+
const arraySchema = z.object({ skills: z.array(skillSchema) });
|
|
87
|
+
const parsed = arraySchema.safeParse(raw);
|
|
88
|
+
if (!parsed.success) {
|
|
89
|
+
console.error("[skills] Unexpected response shape from mcp.getMySkillModes:", parsed.error.message);
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
return parsed.data.skills;
|
|
93
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { loadSkillsYaml, skillsForRole, parseSkillModesResponse } from "./loader.js";
|
|
7
|
+
function writeTempYaml(content) {
|
|
8
|
+
const dir = mkdtempSync(join(tmpdir(), "skills-test-"));
|
|
9
|
+
const path = join(dir, "skills.yaml");
|
|
10
|
+
writeFileSync(path, content, "utf-8");
|
|
11
|
+
return {
|
|
12
|
+
path,
|
|
13
|
+
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
test("loadSkillsYaml: parses a minimal skill", () => {
|
|
17
|
+
const { path, cleanup } = writeTempYaml(`
|
|
18
|
+
version: "0.1.0"
|
|
19
|
+
skills:
|
|
20
|
+
- name: hello
|
|
21
|
+
title: "Hello"
|
|
22
|
+
description: "A greeting skill"
|
|
23
|
+
system_prompt: "Say hi to the user."
|
|
24
|
+
`);
|
|
25
|
+
try {
|
|
26
|
+
const parsed = loadSkillsYaml(path);
|
|
27
|
+
assert.equal(parsed.skills.length, 1);
|
|
28
|
+
assert.equal(parsed.skills[0].name, "hello");
|
|
29
|
+
assert.deepEqual(parsed.skills[0].tools_emphasized, []);
|
|
30
|
+
assert.deepEqual(parsed.skills[0].on_load, []);
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
cleanup();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
test("loadSkillsYaml: parses a fully-specified skill", () => {
|
|
37
|
+
const { path, cleanup } = writeTempYaml(`
|
|
38
|
+
version: "0.1.0"
|
|
39
|
+
skills:
|
|
40
|
+
- name: cpq_coach
|
|
41
|
+
title: "CPQ Coach"
|
|
42
|
+
description: "Build quotes"
|
|
43
|
+
system_prompt: "You are a CPQ coach."
|
|
44
|
+
tools_emphasized: [cpq_context, quote_show]
|
|
45
|
+
on_load:
|
|
46
|
+
- tool: cpq_context
|
|
47
|
+
args: {}
|
|
48
|
+
roles: [field, ops]
|
|
49
|
+
`);
|
|
50
|
+
try {
|
|
51
|
+
const parsed = loadSkillsYaml(path);
|
|
52
|
+
const skill = parsed.skills[0];
|
|
53
|
+
assert.equal(skill.name, "cpq_coach");
|
|
54
|
+
assert.deepEqual(skill.tools_emphasized, ["cpq_context", "quote_show"]);
|
|
55
|
+
assert.equal(skill.on_load[0].tool, "cpq_context");
|
|
56
|
+
assert.deepEqual(skill.roles, ["field", "ops"]);
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
cleanup();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
test("loadSkillsYaml: rejects bad slug (caps + dashes)", () => {
|
|
63
|
+
const { path, cleanup } = writeTempYaml(`
|
|
64
|
+
version: "0.1.0"
|
|
65
|
+
skills:
|
|
66
|
+
- name: CPQ-Coach
|
|
67
|
+
title: "Bad"
|
|
68
|
+
description: "Bad"
|
|
69
|
+
system_prompt: "x"
|
|
70
|
+
`);
|
|
71
|
+
try {
|
|
72
|
+
assert.throws(() => loadSkillsYaml(path));
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
cleanup();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
test("loadSkillsYaml: rejects empty system_prompt", () => {
|
|
79
|
+
const { path, cleanup } = writeTempYaml(`
|
|
80
|
+
version: "0.1.0"
|
|
81
|
+
skills:
|
|
82
|
+
- name: empty
|
|
83
|
+
title: "x"
|
|
84
|
+
description: "x"
|
|
85
|
+
system_prompt: ""
|
|
86
|
+
`);
|
|
87
|
+
try {
|
|
88
|
+
assert.throws(() => loadSkillsYaml(path));
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
cleanup();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
const SKILLS = [
|
|
95
|
+
{
|
|
96
|
+
name: "public",
|
|
97
|
+
title: "Public",
|
|
98
|
+
description: "Everyone",
|
|
99
|
+
system_prompt: "x",
|
|
100
|
+
tools_emphasized: [],
|
|
101
|
+
on_load: [],
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: "field_only",
|
|
105
|
+
title: "Field Only",
|
|
106
|
+
description: "Field reps",
|
|
107
|
+
system_prompt: "x",
|
|
108
|
+
tools_emphasized: [],
|
|
109
|
+
on_load: [],
|
|
110
|
+
roles: ["field"],
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: "ops_admin",
|
|
114
|
+
title: "Ops + Admin",
|
|
115
|
+
description: "Privileged",
|
|
116
|
+
system_prompt: "x",
|
|
117
|
+
tools_emphasized: [],
|
|
118
|
+
on_load: [],
|
|
119
|
+
roles: ["ops", "admin"],
|
|
120
|
+
},
|
|
121
|
+
];
|
|
122
|
+
test("skillsForRole: includes public + role-matching skills", () => {
|
|
123
|
+
const fieldSkills = skillsForRole(SKILLS, "field");
|
|
124
|
+
const names = fieldSkills.map((s) => s.name).sort();
|
|
125
|
+
assert.deepEqual(names, ["field_only", "public"]);
|
|
126
|
+
});
|
|
127
|
+
test("skillsForRole: excludes role-gated skills the caller doesn't have", () => {
|
|
128
|
+
const fieldSkills = skillsForRole(SKILLS, "field");
|
|
129
|
+
assert.equal(fieldSkills.find((s) => s.name === "ops_admin"), undefined);
|
|
130
|
+
});
|
|
131
|
+
test("skillsForRole: multi-role skills visible when caller matches any role", () => {
|
|
132
|
+
const adminSkills = skillsForRole(SKILLS, "admin");
|
|
133
|
+
assert.ok(adminSkills.some((s) => s.name === "ops_admin"));
|
|
134
|
+
});
|
|
135
|
+
test("skillsForRole: caller with no match sees only public skills", () => {
|
|
136
|
+
const guestSkills = skillsForRole(SKILLS, "stranger");
|
|
137
|
+
assert.deepEqual(guestSkills.map((s) => s.name), ["public"]);
|
|
138
|
+
});
|
|
139
|
+
test("parseSkillModesResponse: extracts skills from a well-formed response", () => {
|
|
140
|
+
const raw = {
|
|
141
|
+
skills: [
|
|
142
|
+
{
|
|
143
|
+
name: "test_skill",
|
|
144
|
+
title: "Test",
|
|
145
|
+
description: "Test skill",
|
|
146
|
+
system_prompt: "test",
|
|
147
|
+
tools_emphasized: ["a", "b"],
|
|
148
|
+
on_load: [],
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
const skills = parseSkillModesResponse(raw);
|
|
153
|
+
assert.equal(skills.length, 1);
|
|
154
|
+
assert.equal(skills[0].name, "test_skill");
|
|
155
|
+
});
|
|
156
|
+
test("parseSkillModesResponse: returns empty array on bad shape", () => {
|
|
157
|
+
const skills = parseSkillModesResponse({ wrong: "shape" });
|
|
158
|
+
assert.deepEqual(skills, []);
|
|
159
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
/**
|
|
3
|
+
* CPQ tools — surface for the cpq_coach skill.
|
|
4
|
+
*
|
|
5
|
+
* The skill in config/mcp/skills.yaml bundles these into a workflow
|
|
6
|
+
* mode (system prompt + tool emphasis + auto-load context). When a
|
|
7
|
+
* user invokes /cpq_coach, the MCP client loads the prompt + Claude
|
|
8
|
+
* is told to call `cpq_context` as turn-1.
|
|
9
|
+
*
|
|
10
|
+
* Authorization gates: each tool is conditionally registered based on
|
|
11
|
+
* the user's role grant from config/mcp/role-tool-grants.yaml.
|
|
12
|
+
*/
|
|
13
|
+
export declare function registerCpqTools(server: McpServer, allowed: Set<string>): void;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { trpcQuery } from "../api-client.js";
|
|
2
|
+
import { safeTool } from "./_helpers.js";
|
|
3
|
+
/**
|
|
4
|
+
* CPQ tools — surface for the cpq_coach skill.
|
|
5
|
+
*
|
|
6
|
+
* The skill in config/mcp/skills.yaml bundles these into a workflow
|
|
7
|
+
* mode (system prompt + tool emphasis + auto-load context). When a
|
|
8
|
+
* user invokes /cpq_coach, the MCP client loads the prompt + Claude
|
|
9
|
+
* is told to call `cpq_context` as turn-1.
|
|
10
|
+
*
|
|
11
|
+
* Authorization gates: each tool is conditionally registered based on
|
|
12
|
+
* the user's role grant from config/mcp/role-tool-grants.yaml.
|
|
13
|
+
*/
|
|
14
|
+
export function registerCpqTools(server, allowed) {
|
|
15
|
+
if (allowed.has("cpq_context")) {
|
|
16
|
+
server.tool("cpq_context", "Loads the full ClickHouse CPQ context: the playbook (tier guidance, sizing heuristics, commit strategy, discount approach, validation rules, common mistakes, FAQ), the product catalog (16 products with rates), and the discount schedule (commit × term grid). Call this ONCE per CPQ conversation before answering any pricing or quote-building question. The cpq_coach skill auto-loads this.", {}, safeTool(async () => trpcQuery("mcp.cpqContext")));
|
|
17
|
+
}
|
|
18
|
+
}
|
package/dist/tools/reporting.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { trpcMutation, trpcQuery } from "../api-client.js";
|
|
2
3
|
import { safeTool } from "./_helpers.js";
|
|
3
4
|
import { rangeSchema, salesforceLeadIdSchema, } from "./_schemas.js";
|
|
4
5
|
export function registerReportingTools(server, allowed) {
|
|
@@ -21,4 +22,40 @@ export function registerReportingTools(server, allowed) {
|
|
|
21
22
|
leadId: salesforceLeadIdSchema.describe("Salesforce Lead ID (e.g., 00QgL00000BQL0tUAH)."),
|
|
22
23
|
}, safeTool(async ({ leadId }) => trpcQuery("mcp.decisionExplain", { leadId })));
|
|
23
24
|
}
|
|
25
|
+
if (allowed.has("create_prep_brief")) {
|
|
26
|
+
server.tool("create_prep_brief", "Generate a standardized prep brief for a customer-lifecycle conversation. One tool, 7 conversation types covering the full deal cycle (account_research, discovery, use_case_validation, technical_validation, economic_buyer, executive_sponsor, renewal_expansion). Pulls from Account Master + Salesforce + Signal Scanner + web research; composes via Claude. Optional localContent for the rep's own notes/prep docs (paste content or use Read() in Claude Code). MEDDPICC-anchored for deal-stage briefs. See docs/plans/create-prep-brief.md.", {
|
|
27
|
+
accountDomain: z
|
|
28
|
+
.string()
|
|
29
|
+
.describe("Account domain, e.g. 'acme.com'."),
|
|
30
|
+
conversationType: z
|
|
31
|
+
.enum([
|
|
32
|
+
"account_research",
|
|
33
|
+
"discovery",
|
|
34
|
+
"use_case_validation",
|
|
35
|
+
"technical_validation",
|
|
36
|
+
"economic_buyer",
|
|
37
|
+
"executive_sponsor",
|
|
38
|
+
"renewal_expansion",
|
|
39
|
+
])
|
|
40
|
+
.describe("Which conversation the brief is for. Selects the template + data fetchers."),
|
|
41
|
+
meetingContext: z
|
|
42
|
+
.string()
|
|
43
|
+
.optional()
|
|
44
|
+
.describe("Optional one-line context about the meeting (e.g. 'Mike from IT, technical scoping')."),
|
|
45
|
+
localContent: z
|
|
46
|
+
.string()
|
|
47
|
+
.optional()
|
|
48
|
+
.describe("Optional: rep's local notes / prep doc to include verbatim. Claude Code: Read the file then pass content. Desktop: paste/drag-drop."),
|
|
49
|
+
style: z
|
|
50
|
+
.enum(["concise", "standard", "detailed"])
|
|
51
|
+
.optional()
|
|
52
|
+
.describe("Output verbosity. concise ~400 words; standard ~900 (default); detailed ~1500."),
|
|
53
|
+
}, safeTool(async ({ accountDomain, conversationType, meetingContext, localContent, style }) => trpcMutation("mcp.createPrepBrief", {
|
|
54
|
+
accountDomain,
|
|
55
|
+
conversationType,
|
|
56
|
+
meetingContext,
|
|
57
|
+
localContent,
|
|
58
|
+
style: style ?? "standard",
|
|
59
|
+
})));
|
|
60
|
+
}
|
|
24
61
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loopops/mcp-server",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.43.0",
|
|
4
4
|
"description": "Loop Operations MCP Server — AI skills for RevOps",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -20,6 +20,12 @@
|
|
|
20
20
|
"publishConfig": {
|
|
21
21
|
"access": "public"
|
|
22
22
|
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc",
|
|
25
|
+
"dev": "tsx src/index.ts",
|
|
26
|
+
"start": "node dist/index.js",
|
|
27
|
+
"prepublishOnly": "pnpm build"
|
|
28
|
+
},
|
|
23
29
|
"dependencies": {
|
|
24
30
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
25
31
|
"zod": "^3.24.4"
|
|
@@ -28,10 +34,5 @@
|
|
|
28
34
|
"@types/node": "^22.15.21",
|
|
29
35
|
"tsx": "^4.19.4",
|
|
30
36
|
"typescript": "^5.8.3"
|
|
31
|
-
},
|
|
32
|
-
"scripts": {
|
|
33
|
-
"build": "tsc",
|
|
34
|
-
"dev": "tsx src/index.ts",
|
|
35
|
-
"start": "node dist/index.js"
|
|
36
37
|
}
|
|
37
|
-
}
|
|
38
|
+
}
|