@schematichq/schematic-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 SchematicHQ
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # Schematic MCP Server
2
+
3
+ An [MCP](https://modelcontextprotocol.io/) server that connects AI assistants to [Schematic](https://schematichq.com) -- the platform for managing billing, plans, features, and entitlements.
4
+
5
+ Use this server to let Claude, Cursor, or any MCP-compatible client look up companies, manage plan entitlements, set overrides, create features, and more -- all through natural language.
6
+
7
+ ## Quick Start
8
+
9
+ ### Claude Desktop / Claude Code
10
+
11
+ Add to your Claude config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
12
+
13
+ ```json
14
+ {
15
+ "mcpServers": {
16
+ "schematic": {
17
+ "command": "npx",
18
+ "args": ["-y", "@schematichq/schematic-mcp"],
19
+ "env": {
20
+ "SCHEMATIC_API_KEY": "your-api-key-here"
21
+ }
22
+ }
23
+ }
24
+ }
25
+ ```
26
+
27
+ ### Cursor
28
+
29
+ Add the same configuration to Cursor's MCP config:
30
+
31
+ - **macOS**: `~/Library/Application Support/Cursor/User/globalStorage/mcp.json`
32
+ - **Linux**: `~/.config/Cursor/User/globalStorage/mcp.json`
33
+ - **Windows**: `%APPDATA%\Cursor\User\globalStorage\mcp.json`
34
+
35
+ ## Configuration
36
+
37
+ The server needs a Schematic API key. It checks two sources in order:
38
+
39
+ 1. **Environment variable** (recommended): `SCHEMATIC_API_KEY`
40
+ 2. **Config file** (fallback): `~/.schematic-mcp/config.json`
41
+
42
+ ```json
43
+ {
44
+ "apiKey": "your-api-key-here"
45
+ }
46
+ ```
47
+
48
+ You can find your API key in the [Schematic dashboard](https://app.schematichq.com).
49
+
50
+ ## Tools
51
+
52
+ ### Company Lookup
53
+
54
+ | Tool | Description |
55
+ |------|-------------|
56
+ | `get_company` | Look up a company by ID, name, Stripe customer ID, or [custom key](https://docs.schematichq.com/developer_resources/key_management). Returns details, plan, trial status, and links. |
57
+ | `get_company_plan` | Get the plan a company is currently on. |
58
+ | `get_company_trial_info` | Check if a company is on a trial and when it ends. |
59
+ | `count_companies_on_plan` | Count how many companies are on a specific plan. |
60
+ | `link_stripe_to_schematic` | Find the Schematic company for a Stripe customer ID, or vice versa. |
61
+
62
+ ### Company Overrides
63
+
64
+ | Tool | Description |
65
+ |------|-------------|
66
+ | `list_company_overrides` | List overrides by company or by feature. |
67
+ | `set_company_override` | Set or update an override for a company on a specific feature. Supports boolean (`on`/`off`), numeric, and `unlimited` values. |
68
+ | `remove_company_override` | Remove an override so the company falls back to plan entitlements. |
69
+
70
+ ### Plan Management
71
+
72
+ | Tool | Description |
73
+ |------|-------------|
74
+ | `list_plans` | List all plans. |
75
+ | `create_plan` | Create a new plan. |
76
+ | `add_entitlements_to_plan` | Add feature entitlements to a plan. Auto-detects feature type and sets appropriate value types. |
77
+
78
+ ### Feature Management
79
+
80
+ | Tool | Description |
81
+ |------|-------------|
82
+ | `list_features` | List all features. |
83
+ | `create_feature` | Create a new feature flag. Supports boolean (on/off), event-based (metered), and trait-based types. Automatically creates an associated flag. |
84
+
85
+ ## Example Prompts
86
+
87
+ Once configured, try asking your AI assistant:
88
+
89
+ - "What plan is Acme Corp on?"
90
+ - "List all my plans and their features"
91
+ - "Create a boolean feature called 'Advanced Analytics'"
92
+ - "Set an override for Acme Corp to have unlimited API calls"
93
+ - "How many companies are on the Pro plan?"
94
+ - "Find the Schematic company linked to Stripe customer cus_abc123"
95
+
96
+ ## Development
97
+
98
+ ```bash
99
+ # Install dependencies
100
+ npm install
101
+
102
+ # Build
103
+ npm run build
104
+
105
+ # Run in development mode (auto-recompile on changes)
106
+ npm run dev
107
+
108
+ # Run tests
109
+ npm test
110
+ ```
111
+
112
+ ## License
113
+
114
+ MIT
package/dist/config.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Configuration management for Schematic MCP Server
3
+ * Supports environment variables (primary) and config file (fallback)
4
+ */
5
+ import { readFileSync } from "fs";
6
+ import { join } from "path";
7
+ import { homedir } from "os";
8
+ export function getApiKey() {
9
+ // Try environment variable first (most common for MCP)
10
+ const envKey = process.env.SCHEMATIC_API_KEY;
11
+ if (envKey) {
12
+ return envKey;
13
+ }
14
+ // Fallback to config file
15
+ try {
16
+ const configPath = join(homedir(), ".schematic-mcp", "config.json");
17
+ const config = JSON.parse(readFileSync(configPath, "utf-8"));
18
+ if (config.apiKey) {
19
+ return config.apiKey;
20
+ }
21
+ }
22
+ catch (error) {
23
+ // Config file doesn't exist or is invalid, that's okay
24
+ }
25
+ throw new Error("SCHEMATIC_API_KEY environment variable or config file (~/.schematic-mcp/config.json) is required");
26
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Helper functions for company resolution and data formatting
3
+ */
4
+ const PAGE_SIZE = 100;
5
+ /**
6
+ * Fetches all pages from a paginated Schematic list endpoint.
7
+ */
8
+ export async function fetchAll(listFn, baseParams) {
9
+ const allItems = [];
10
+ let offset = 0;
11
+ while (true) {
12
+ const response = await listFn({ ...baseParams, limit: PAGE_SIZE, offset });
13
+ const items = response.data || [];
14
+ allItems.push(...items);
15
+ if (items.length < PAGE_SIZE)
16
+ break;
17
+ offset += PAGE_SIZE;
18
+ }
19
+ return allItems;
20
+ }
21
+ /**
22
+ * Resolves a company using various identifier types
23
+ */
24
+ export async function resolveCompany(client, identifier) {
25
+ // Direct company ID lookup
26
+ if (identifier.companyId) {
27
+ const response = await client.companies.getCompany(identifier.companyId);
28
+ return response.data;
29
+ }
30
+ // Stripe customer ID lookup
31
+ if (identifier.stripeCustomerId) {
32
+ const response = await client.companies.lookupCompany({
33
+ keys: {
34
+ stripe_customer_id: identifier.stripeCustomerId,
35
+ },
36
+ });
37
+ return response.data;
38
+ }
39
+ // Company name search
40
+ if (identifier.companyName) {
41
+ const companies = await fetchAll((params) => client.companies.listCompanies(params), { q: identifier.companyName });
42
+ if (companies.length === 0) {
43
+ throw new Error(`No company found with name "${identifier.companyName}"`);
44
+ }
45
+ if (companies.length > 1) {
46
+ const names = companies.map((c) => c.name || c.id).join(", ");
47
+ throw new Error(`Multiple companies found matching "${identifier.companyName}": ${names}. Please be more specific or use company ID.`);
48
+ }
49
+ return companies[0];
50
+ }
51
+ // Custom key lookup
52
+ if (identifier.keyName || identifier.keyValue) {
53
+ if (!identifier.keyName || !identifier.keyValue) {
54
+ throw new Error("Both keyName and keyValue are required for custom key lookup. " +
55
+ "Key names are configured in Schematic - see https://docs.schematichq.com/developer_resources/key_management");
56
+ }
57
+ const response = await client.companies.lookupCompany({
58
+ keys: {
59
+ [identifier.keyName]: identifier.keyValue,
60
+ },
61
+ });
62
+ return response.data;
63
+ }
64
+ throw new Error("No valid company identifier provided");
65
+ }
66
+ /**
67
+ * Resolves a feature by ID or name (matches on name or flag key)
68
+ */
69
+ export async function resolveFeature(client, identifier) {
70
+ if (identifier.featureId) {
71
+ const response = await client.features.getFeature(identifier.featureId);
72
+ return response.data;
73
+ }
74
+ if (identifier.featureName) {
75
+ const features = await fetchAll((params) => client.features.listFeatures(params), {});
76
+ const feature = features.find((f) => f.name === identifier.featureName || f.flags?.[0]?.key === identifier.featureName);
77
+ if (!feature) {
78
+ throw new Error(`Feature "${identifier.featureName}" not found`);
79
+ }
80
+ return feature;
81
+ }
82
+ throw new Error("Either featureId or featureName is required");
83
+ }
84
+ /**
85
+ * Resolves a plan by ID or name
86
+ */
87
+ export async function resolvePlan(client, identifier) {
88
+ if (identifier.planId) {
89
+ const response = await client.plans.getPlan(identifier.planId);
90
+ return response.data;
91
+ }
92
+ if (identifier.planName) {
93
+ const plans = await fetchAll((params) => client.plans.listPlans(params), {});
94
+ const plan = plans.find((p) => p.name === identifier.planName);
95
+ if (!plan) {
96
+ throw new Error(`Plan "${identifier.planName}" not found`);
97
+ }
98
+ return plan;
99
+ }
100
+ throw new Error("Either planId or planName is required");
101
+ }
102
+ /**
103
+ * Generates a SchematicHQ URL for a company
104
+ */
105
+ export function getSchematicCompanyUrl(companyId) {
106
+ return `https://app.schematichq.com/env/companies/${companyId}`;
107
+ }
108
+ /**
109
+ * Generates a Stripe dashboard URL for a customer
110
+ */
111
+ export function getStripeCustomerUrl(stripeCustomerId) {
112
+ return `https://dashboard.stripe.com/customers/${stripeCustomerId}`;
113
+ }
package/dist/index.js ADDED
@@ -0,0 +1,765 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SchematicHQ MCP Server
4
+ * Provides tools for managing companies, plans, features, and billing
5
+ */
6
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
7
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import { CallToolRequestSchema, ListToolsRequestSchema, ErrorCode, McpError, } from "@modelcontextprotocol/sdk/types.js";
9
+ import { SchematicClient } from "@schematichq/schematic-typescript-node";
10
+ import { getApiKey } from "./config.js";
11
+ import { resolveCompany, resolveFeature, resolvePlan, fetchAll, getSchematicCompanyUrl, getStripeCustomerUrl } from "./helpers.js";
12
+ // Initialize Schematic client lazily
13
+ let schematicClient = null;
14
+ function getSchematicClient() {
15
+ if (!schematicClient) {
16
+ const apiKey = getApiKey();
17
+ schematicClient = new SchematicClient({ apiKey });
18
+ }
19
+ return schematicClient;
20
+ }
21
+ // Create MCP server
22
+ const server = new Server({
23
+ name: "schematic-mcp-server",
24
+ version: "1.0.0",
25
+ }, {
26
+ capabilities: {
27
+ tools: {},
28
+ },
29
+ });
30
+ // Helper to format tool responses
31
+ function textResponse(text) {
32
+ return {
33
+ content: [
34
+ {
35
+ type: "text",
36
+ text,
37
+ },
38
+ ],
39
+ };
40
+ }
41
+ // Helper to check if a string is properly capitalized (Title Case)
42
+ function isTitleCase(str) {
43
+ if (!str || str.length === 0)
44
+ return false;
45
+ // Check if first letter is uppercase and rest follows title case rules
46
+ const words = str.split(/\s+/);
47
+ return words.every(word => {
48
+ if (word.length === 0)
49
+ return true;
50
+ const firstChar = word[0];
51
+ const rest = word.slice(1);
52
+ return firstChar === firstChar.toUpperCase() && rest === rest.toLowerCase();
53
+ });
54
+ }
55
+ // Helper to convert a string to Title Case
56
+ function toTitleCase(str) {
57
+ return str
58
+ .split(/\s+/)
59
+ .map(word => {
60
+ if (word.length === 0)
61
+ return word;
62
+ return word[0].toUpperCase() + word.slice(1).toLowerCase();
63
+ })
64
+ .join(' ');
65
+ }
66
+ // Helper to format an override value for display
67
+ function formatOverrideValue(override) {
68
+ if (override.valueType === "unlimited")
69
+ return "unlimited";
70
+ if (override.valueBool !== undefined)
71
+ return override.valueBool ? "on" : "off";
72
+ if (override.valueNumeric !== undefined)
73
+ return String(override.valueNumeric);
74
+ return "unknown";
75
+ }
76
+ // Helper to safely extract a string argument from tool args
77
+ function stringArg(args, key) {
78
+ const val = args?.[key];
79
+ if (val === undefined || val === null)
80
+ return undefined;
81
+ if (typeof val !== "string") {
82
+ throw new Error(`Expected "${key}" to be a string, got ${typeof val}`);
83
+ }
84
+ return val;
85
+ }
86
+ // Helper to safely extract a required string argument from tool args
87
+ function requiredStringArg(args, key) {
88
+ const val = stringArg(args, key);
89
+ if (!val) {
90
+ throw new Error(`"${key}" is required`);
91
+ }
92
+ return val;
93
+ }
94
+ // Helper to safely extract an array argument from tool args
95
+ function arrayArg(args, key) {
96
+ const val = args?.[key];
97
+ if (val === undefined || val === null)
98
+ return undefined;
99
+ if (!Array.isArray(val)) {
100
+ throw new Error(`Expected "${key}" to be an array, got ${typeof val}`);
101
+ }
102
+ return val;
103
+ }
104
+ // Helper to generate a flag key from a feature name
105
+ function generateFlagKey(name) {
106
+ // Convert to lowercase and replace spaces/special chars with underscores
107
+ return name
108
+ .toLowerCase()
109
+ .replace(/[^a-z0-9]+/g, '_')
110
+ .replace(/^_+|_+$/g, '');
111
+ }
112
+ // Register tools
113
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
114
+ return {
115
+ tools: [
116
+ // Company Lookup & Billing Tools
117
+ {
118
+ name: "get_company",
119
+ description: "Get company information by ID, name, Stripe customer ID, or custom key. Returns company details including plan, trial status, and links. For custom key lookups, the user must provide both keyName and keyValue. Key names are configured in Schematic - see https://docs.schematichq.com/developer_resources/key_management for details.",
120
+ inputSchema: {
121
+ type: "object",
122
+ properties: {
123
+ companyId: {
124
+ type: "string",
125
+ description: "Schematic company ID (e.g., comp_xxx)",
126
+ },
127
+ companyName: {
128
+ type: "string",
129
+ description: "Company name to search for",
130
+ },
131
+ stripeCustomerId: {
132
+ type: "string",
133
+ description: "Stripe customer ID",
134
+ },
135
+ keyName: {
136
+ type: "string",
137
+ description: "Custom key name to look up the company by (e.g., 'app_id'). Must be used with keyValue. See https://docs.schematichq.com/developer_resources/key_management",
138
+ },
139
+ keyValue: {
140
+ type: "string",
141
+ description: "Custom key value to look up the company by. Must be used with keyName.",
142
+ },
143
+ },
144
+ },
145
+ },
146
+ {
147
+ name: "get_company_plan",
148
+ description: "Get the plan that a company is currently on",
149
+ inputSchema: {
150
+ type: "object",
151
+ properties: {
152
+ companyId: { type: "string" },
153
+ companyName: { type: "string" },
154
+ stripeCustomerId: { type: "string" },
155
+ keyName: { type: "string", description: "Custom key name for company lookup (requires keyValue)" },
156
+ keyValue: { type: "string", description: "Custom key value for company lookup (requires keyName)" },
157
+ },
158
+ },
159
+ },
160
+ {
161
+ name: "get_company_trial_info",
162
+ description: "Check if a company is on a trial and when it ends",
163
+ inputSchema: {
164
+ type: "object",
165
+ properties: {
166
+ companyId: { type: "string" },
167
+ companyName: { type: "string" },
168
+ stripeCustomerId: { type: "string" },
169
+ keyName: { type: "string", description: "Custom key name for company lookup (requires keyValue)" },
170
+ keyValue: { type: "string", description: "Custom key value for company lookup (requires keyName)" },
171
+ },
172
+ },
173
+ },
174
+ {
175
+ name: "count_companies_on_plan",
176
+ description: "Count how many companies are on a specific plan",
177
+ inputSchema: {
178
+ type: "object",
179
+ properties: {
180
+ planId: { type: "string", description: "Plan ID (e.g., plan_xxx)" },
181
+ planName: { type: "string", description: "Plan name" },
182
+ },
183
+ },
184
+ },
185
+ {
186
+ name: "link_stripe_to_schematic",
187
+ description: "Find the Schematic company for a Stripe customer ID, or vice versa. Returns both IDs and links to both platforms.",
188
+ inputSchema: {
189
+ type: "object",
190
+ properties: {
191
+ stripeCustomerId: {
192
+ type: "string",
193
+ description: "Stripe customer ID",
194
+ },
195
+ companyId: {
196
+ type: "string",
197
+ description: "Schematic company ID",
198
+ },
199
+ },
200
+ },
201
+ },
202
+ // Company Overrides
203
+ {
204
+ name: "list_company_overrides",
205
+ description: "List company overrides. Filter by company (to see all overrides for a company) or by feature (to see which companies have an override for a feature)",
206
+ inputSchema: {
207
+ type: "object",
208
+ properties: {
209
+ companyId: { type: "string", description: "Company ID to filter by" },
210
+ companyName: { type: "string", description: "Company name to filter by" },
211
+ featureName: { type: "string", description: "Feature name to filter by (finds which companies have an override for this feature)" },
212
+ featureId: { type: "string", description: "Feature ID to filter by" },
213
+ },
214
+ },
215
+ },
216
+ {
217
+ name: "set_company_override",
218
+ description: "Set or update a company override for a feature/entitlement. REQUIRES a value parameter - always ask the user for the desired value before calling this tool. For boolean features: use 'on'/'off' or 'true'/'false'. For event-based or trait-based features: use a numeric value (e.g., '10', '100') or 'unlimited'.",
219
+ inputSchema: {
220
+ type: "object",
221
+ properties: {
222
+ companyId: { type: "string" },
223
+ companyName: { type: "string" },
224
+ featureName: { type: "string" },
225
+ featureId: { type: "string" },
226
+ value: {
227
+ type: "string",
228
+ description: "REQUIRED: Override value. For boolean features: 'on'/'off' or 'true'/'false'. For event-based or trait-based features: a numeric value as a string (e.g., '10', '100') or 'unlimited'. Always ask the user for this value if not provided.",
229
+ },
230
+ },
231
+ required: ["value"],
232
+ },
233
+ },
234
+ {
235
+ name: "remove_company_override",
236
+ description: "Remove a company override for a feature/entitlement. This will delete the override and the company will fall back to their plan's entitlements.",
237
+ inputSchema: {
238
+ type: "object",
239
+ properties: {
240
+ companyId: { type: "string" },
241
+ companyName: { type: "string" },
242
+ featureName: { type: "string" },
243
+ featureId: { type: "string" },
244
+ },
245
+ },
246
+ },
247
+ // Plan Management
248
+ {
249
+ name: "list_plans",
250
+ description: "List all plans in your Schematic account",
251
+ inputSchema: {
252
+ type: "object",
253
+ properties: {},
254
+ },
255
+ },
256
+ {
257
+ name: "create_plan",
258
+ description: "Create a new plan",
259
+ inputSchema: {
260
+ type: "object",
261
+ properties: {
262
+ name: { type: "string", description: "Plan name" },
263
+ description: { type: "string", description: "Plan description" },
264
+ },
265
+ required: ["name"],
266
+ },
267
+ },
268
+ {
269
+ name: "add_entitlements_to_plan",
270
+ description: "Add entitlements to a plan. The feature type will be automatically determined by querying the feature. For boolean features, defaults to 'on' if no value is provided. For event-based or trait-based features, a value (number or 'unlimited') is required.",
271
+ inputSchema: {
272
+ type: "object",
273
+ properties: {
274
+ planId: { type: "string" },
275
+ planName: { type: "string" },
276
+ entitlements: {
277
+ type: "array",
278
+ description: "Array of entitlement configurations. For boolean features, value is optional (defaults to 'on'). For event/trait features, value is required.",
279
+ items: {
280
+ type: "object",
281
+ properties: {
282
+ featureId: { type: "string" },
283
+ featureName: { type: "string" },
284
+ value: {
285
+ type: "string",
286
+ description: "Optional for boolean features (defaults to 'on'). Required for event/trait features: a number as string (e.g., '10', '100') or 'unlimited'.",
287
+ },
288
+ },
289
+ },
290
+ },
291
+ },
292
+ required: ["entitlements"],
293
+ },
294
+ },
295
+ // Feature Management
296
+ {
297
+ name: "list_features",
298
+ description: "List all features in your Schematic account",
299
+ inputSchema: {
300
+ type: "object",
301
+ properties: {},
302
+ },
303
+ },
304
+ {
305
+ name: "create_feature",
306
+ description: "Create a new feature flag. Boolean features are simple on/off switches - the most commonly used type, ideal for enabling/disabling functionality and basic plan differentiation. Event-based features are metered against user events and track usage that typically increases over time (e.g., API calls, reports generated, database queries). Trait-based features are based on information reported to Schematic and can track usage that fluctuates up and down (e.g., user seats, projects, devices). Trait-based features must be created in the web app. Optionally entitle the feature to a plan in the same call.",
307
+ inputSchema: {
308
+ type: "object",
309
+ properties: {
310
+ name: { type: "string", description: "Feature name/key" },
311
+ description: { type: "string", description: "Optional: Feature description" },
312
+ featureType: {
313
+ type: "string",
314
+ enum: ["boolean", "event", "trait"],
315
+ description: "Feature type: 'boolean' (simple on/off switch, most common), 'event' (metered against events that increase over time), or 'trait' (based on information that can fluctuate - must be created in web app). Defaults to 'boolean' if not specified.",
316
+ },
317
+ eventSubtype: {
318
+ type: "string",
319
+ description: "REQUIRED for event-based features: The event subtype to associate with this feature (e.g., 'api_call', 'report_generated').",
320
+ },
321
+ planId: {
322
+ type: "string",
323
+ description: "Optional: Plan ID to entitle this feature to",
324
+ },
325
+ planName: {
326
+ type: "string",
327
+ description: "Optional: Plan name to entitle this feature to",
328
+ },
329
+ },
330
+ required: ["name"],
331
+ },
332
+ },
333
+ ],
334
+ };
335
+ });
336
+ // Handle tool calls
337
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
338
+ const { name, arguments: args } = request.params;
339
+ try {
340
+ switch (name) {
341
+ case "get_company": {
342
+ const company = await resolveCompany(getSchematicClient(), {
343
+ companyId: stringArg(args, "companyId"),
344
+ companyName: stringArg(args, "companyName"),
345
+ stripeCustomerId: stringArg(args, "stripeCustomerId"),
346
+ keyName: stringArg(args, "keyName"),
347
+ keyValue: stringArg(args, "keyValue"),
348
+ });
349
+ // Helper function to format trial end date (Unix timestamp in seconds)
350
+ const formatTrialEnd = (trialEnd) => {
351
+ if (!trialEnd)
352
+ return "Not on trial";
353
+ const trialEndDate = new Date(trialEnd * 1000);
354
+ return `Trial ends: ${trialEndDate.toLocaleString('en-US', {
355
+ weekday: 'long',
356
+ year: 'numeric',
357
+ month: 'long',
358
+ day: 'numeric',
359
+ hour: 'numeric',
360
+ minute: '2-digit',
361
+ timeZoneName: 'short'
362
+ })}`;
363
+ };
364
+ const info = [
365
+ `Company: ${company.name || company.id}`,
366
+ `ID: ${company.id}`,
367
+ company.plan ? `Plan ID: ${company.plan.id}` : "No plan assigned",
368
+ formatTrialEnd(company.billingSubscription?.trialEnd),
369
+ `Schematic: ${getSchematicCompanyUrl(company.id)}`,
370
+ ];
371
+ const stripeKey = company.keys?.find((k) => k.key === "stripe_customer_id");
372
+ if (stripeKey) {
373
+ info.push(`Stripe Customer ID: ${stripeKey.value}`, `Stripe: ${getStripeCustomerUrl(stripeKey.value)}`);
374
+ }
375
+ return textResponse(info.join("\n"));
376
+ }
377
+ case "get_company_plan": {
378
+ const company = await resolveCompany(getSchematicClient(), {
379
+ companyId: stringArg(args, "companyId"),
380
+ companyName: stringArg(args, "companyName"),
381
+ stripeCustomerId: stringArg(args, "stripeCustomerId"),
382
+ keyName: stringArg(args, "keyName"),
383
+ keyValue: stringArg(args, "keyValue"),
384
+ });
385
+ if (!company.plan?.id) {
386
+ return textResponse(`Company ${company.name || company.id} is not on any plan.`);
387
+ }
388
+ // Fetch plan details
389
+ const planResponse = await getSchematicClient().plans.getPlan(company.plan.id);
390
+ const plan = planResponse.data;
391
+ return textResponse(`Company ${company.name || company.id} is on plan: ${plan.name} (${plan.id})`);
392
+ }
393
+ case "get_company_trial_info": {
394
+ const company = await resolveCompany(getSchematicClient(), {
395
+ companyId: stringArg(args, "companyId"),
396
+ companyName: stringArg(args, "companyName"),
397
+ stripeCustomerId: stringArg(args, "stripeCustomerId"),
398
+ keyName: stringArg(args, "keyName"),
399
+ keyValue: stringArg(args, "keyValue"),
400
+ });
401
+ const trialEnd = company.billingSubscription?.trialEnd;
402
+ if (!trialEnd) {
403
+ return textResponse(`Company ${company.name || company.id} is not on a trial.`);
404
+ }
405
+ // Convert trialEnd (Unix timestamp in seconds) to readable date format
406
+ const trialEndDate = new Date(trialEnd * 1000);
407
+ const formattedDate = trialEndDate.toLocaleString('en-US', {
408
+ weekday: 'long',
409
+ year: 'numeric',
410
+ month: 'long',
411
+ day: 'numeric',
412
+ hour: 'numeric',
413
+ minute: '2-digit',
414
+ timeZoneName: 'short'
415
+ });
416
+ return textResponse(`Company ${company.name || company.id} is on a trial.\nTrial ends: ${formattedDate}`);
417
+ }
418
+ case "count_companies_on_plan": {
419
+ const plan = await resolvePlan(getSchematicClient(), {
420
+ planId: stringArg(args, "planId"),
421
+ planName: stringArg(args, "planName"),
422
+ });
423
+ const count = plan.companyCount || 0;
424
+ return textResponse(`${count} compan${count !== 1 ? "ies" : "y"} ${count !== 1 ? "are" : "is"} on plan ${plan.name || plan.id}`);
425
+ }
426
+ case "link_stripe_to_schematic": {
427
+ const stripeCustomerId = stringArg(args, "stripeCustomerId");
428
+ const companyId = stringArg(args, "companyId");
429
+ if (stripeCustomerId) {
430
+ const company = await resolveCompany(getSchematicClient(), {
431
+ stripeCustomerId,
432
+ });
433
+ const info = [
434
+ `Stripe Customer ID: ${stripeCustomerId}`,
435
+ `Schematic Company: ${company.name || company.id}`,
436
+ `Schematic Company ID: ${company.id}`,
437
+ `Schematic: ${getSchematicCompanyUrl(company.id)}`,
438
+ `Stripe: ${getStripeCustomerUrl(stripeCustomerId)}`,
439
+ ];
440
+ return textResponse(info.join("\n"));
441
+ }
442
+ else if (companyId) {
443
+ const company = await resolveCompany(getSchematicClient(), { companyId });
444
+ const stripeKey = company.keys?.find((k) => k.key === "stripe_customer_id");
445
+ if (!stripeKey) {
446
+ return textResponse(`Company ${company.name || company.id} is not linked to a Stripe customer.`);
447
+ }
448
+ const info = [
449
+ `Schematic Company: ${company.name || company.id}`,
450
+ `Schematic Company ID: ${company.id}`,
451
+ `Stripe Customer ID: ${stripeKey.value}`,
452
+ `Schematic: ${getSchematicCompanyUrl(company.id)}`,
453
+ `Stripe: ${getStripeCustomerUrl(stripeKey.value)}`,
454
+ ];
455
+ return textResponse(info.join("\n"));
456
+ }
457
+ else {
458
+ throw new Error("Either stripeCustomerId or companyId is required");
459
+ }
460
+ }
461
+ case "list_company_overrides": {
462
+ const companyId = stringArg(args, "companyId");
463
+ const companyName = stringArg(args, "companyName");
464
+ const featureName = stringArg(args, "featureName");
465
+ const featureId = stringArg(args, "featureId");
466
+ // Build filter parameters
467
+ const filterParams = {
468
+ limit: 100, // Reasonable limit, but companies typically have < 10 overrides
469
+ };
470
+ // Resolve company if filtering by company
471
+ let company;
472
+ if (companyId || companyName) {
473
+ company = await resolveCompany(getSchematicClient(), {
474
+ companyId,
475
+ companyName,
476
+ });
477
+ filterParams.companyId = company.id;
478
+ }
479
+ // Resolve feature if filtering by feature
480
+ let resolvedFeature;
481
+ if (featureName || featureId) {
482
+ resolvedFeature = await resolveFeature(getSchematicClient(), { featureId, featureName });
483
+ filterParams.featureId = resolvedFeature.id;
484
+ }
485
+ // Must filter by either company or feature
486
+ if (!filterParams.companyId && !filterParams.featureId) {
487
+ throw new Error("Either companyId/companyName or featureId/featureName is required");
488
+ }
489
+ // Get company overrides
490
+ const overridesResponse = await getSchematicClient().entitlements.listCompanyOverrides(filterParams);
491
+ const overrides = overridesResponse.data || [];
492
+ if (overrides.length === 0) {
493
+ if (company) {
494
+ return textResponse(`Company ${company.name || company.id} has no overrides.`);
495
+ }
496
+ else if (resolvedFeature) {
497
+ return textResponse(`No companies have an override for feature ${resolvedFeature.name || resolvedFeature.id}.`);
498
+ }
499
+ return textResponse("No overrides found.");
500
+ }
501
+ // Format the response
502
+ const results = [];
503
+ if (company) {
504
+ // Listing all overrides for a company - fetch feature names for display
505
+ const features = await fetchAll((params) => getSchematicClient().features.listFeatures(params), {});
506
+ const featureMap = new Map();
507
+ for (const feature of features) {
508
+ featureMap.set(feature.id, feature);
509
+ }
510
+ results.push(`Company ${company.name || company.id} has ${overrides.length} override${overrides.length !== 1 ? "s" : ""}:`);
511
+ for (const override of overrides) {
512
+ const feature = featureMap.get(override.featureId);
513
+ const featureDisplayName = feature?.name || override.featureId;
514
+ results.push(` - ${featureDisplayName} (${override.featureId}): ${formatOverrideValue(override)}`);
515
+ }
516
+ }
517
+ else {
518
+ // Listing all companies with override for a feature
519
+ const displayName = resolvedFeature?.name || resolvedFeature?.id;
520
+ results.push(`${overrides.length} compan${overrides.length !== 1 ? "ies have" : "y has"} an override for feature ${displayName}:`);
521
+ for (const override of overrides) {
522
+ const companyName = override.company?.name || override.companyId;
523
+ results.push(` - ${companyName}: ${formatOverrideValue(override)}`);
524
+ }
525
+ }
526
+ return textResponse(results.join("\n"));
527
+ }
528
+ case "set_company_override": {
529
+ const company = await resolveCompany(getSchematicClient(), {
530
+ companyId: stringArg(args, "companyId"),
531
+ companyName: stringArg(args, "companyName"),
532
+ });
533
+ const featureName = stringArg(args, "featureName");
534
+ const featureId = stringArg(args, "featureId");
535
+ const value = stringArg(args, "value");
536
+ if (!value || value.trim() === "") {
537
+ throw new Error("Value is required. Please provide a value: 'on' or 'off' for boolean features, a number for event-based/trait-based features, or 'unlimited' for unlimited quota.");
538
+ }
539
+ const feature = await resolveFeature(getSchematicClient(), { featureId, featureName });
540
+ const featureType = feature.featureType;
541
+ // Determine value type based on feature type and value
542
+ let requestBody = {
543
+ companyId: company.id,
544
+ featureId: feature.id,
545
+ valueType: "boolean",
546
+ };
547
+ if (value === "on" || value === "off" || value === "true" || value === "false") {
548
+ requestBody.valueType = "boolean";
549
+ requestBody.valueBool = value === "on" || value === "true";
550
+ }
551
+ else if (value === "unlimited") {
552
+ requestBody.valueType = "unlimited";
553
+ }
554
+ else if (!isNaN(Number(value))) {
555
+ if (featureType === "event" || featureType === "trait") {
556
+ requestBody.valueType = "numeric";
557
+ requestBody.valueNumeric = Number(value);
558
+ }
559
+ else {
560
+ throw new Error(`Cannot set numeric override for feature "${feature.name || feature.id}". Numeric overrides are only supported for event-based or trait-based features. This feature is of type "${featureType}".`);
561
+ }
562
+ }
563
+ else {
564
+ // Default to boolean true
565
+ requestBody.valueType = "boolean";
566
+ requestBody.valueBool = true;
567
+ }
568
+ // Create or update override
569
+ await getSchematicClient().entitlements.createCompanyOverride(requestBody);
570
+ return textResponse(`Set override for company ${company.name || company.id}, feature ${feature.name || feature.id}: ${value}`);
571
+ }
572
+ case "remove_company_override": {
573
+ const company = await resolveCompany(getSchematicClient(), {
574
+ companyId: stringArg(args, "companyId"),
575
+ companyName: stringArg(args, "companyName"),
576
+ });
577
+ const feature = await resolveFeature(getSchematicClient(), {
578
+ featureId: stringArg(args, "featureId"),
579
+ featureName: stringArg(args, "featureName"),
580
+ });
581
+ // Find the override for this company and feature
582
+ const overridesResponse = await getSchematicClient().entitlements.listCompanyOverrides({
583
+ companyId: company.id,
584
+ featureId: feature.id,
585
+ limit: 1,
586
+ });
587
+ const overrides = overridesResponse.data || [];
588
+ if (overrides.length === 0) {
589
+ return textResponse(`No override found for company ${company.name || company.id} on feature ${feature.name || feature.id}.`);
590
+ }
591
+ // Delete the override
592
+ await getSchematicClient().entitlements.deleteCompanyOverride(overrides[0].id);
593
+ return textResponse(`Removed override for company ${company.name || company.id} on feature ${feature.name || feature.id}.`);
594
+ }
595
+ case "list_plans": {
596
+ const plans = await fetchAll((params) => getSchematicClient().plans.listPlans(params), {});
597
+ if (plans.length === 0) {
598
+ return textResponse("No plans found.");
599
+ }
600
+ const planList = plans
601
+ .map((plan) => `- ${plan.name} (${plan.id})`)
602
+ .join("\n");
603
+ return textResponse(`Plans:\n${planList}`);
604
+ }
605
+ case "create_plan": {
606
+ const name = requiredStringArg(args, "name");
607
+ const description = stringArg(args, "description");
608
+ const planResponse = await getSchematicClient().plans.createPlan({
609
+ name,
610
+ description: description || "",
611
+ planType: "plan",
612
+ });
613
+ const plan = planResponse.data;
614
+ return textResponse(`Created plan: ${plan.name} (${plan.id})`);
615
+ }
616
+ case "add_entitlements_to_plan": {
617
+ const planId = stringArg(args, "planId");
618
+ const planName = stringArg(args, "planName");
619
+ const entitlements = arrayArg(args, "entitlements");
620
+ const plan = await resolvePlan(getSchematicClient(), {
621
+ planId,
622
+ planName,
623
+ });
624
+ if (!entitlements || entitlements.length === 0) {
625
+ throw new Error("At least one entitlement is required");
626
+ }
627
+ const results = [];
628
+ for (const entitlement of entitlements) {
629
+ const feature = await resolveFeature(getSchematicClient(), {
630
+ featureId: entitlement.featureId,
631
+ featureName: entitlement.featureName,
632
+ });
633
+ const featureType = feature.featureType;
634
+ const featureDisplay = feature.name || feature.id;
635
+ // Prepare entitlement request body
636
+ const entitlementBody = {
637
+ planId: plan.id,
638
+ featureId: feature.id,
639
+ valueType: "boolean",
640
+ };
641
+ if (featureType === "boolean") {
642
+ const value = entitlement.value || "on";
643
+ entitlementBody.valueType = "boolean";
644
+ entitlementBody.valueBool = value === "on" || value === "true";
645
+ }
646
+ else if (featureType === "event" || featureType === "trait") {
647
+ if (!entitlement.value) {
648
+ throw new Error(`Value is required for ${featureType}-based feature "${featureDisplay}". Please provide a number (e.g., "10", "100") or "unlimited".`);
649
+ }
650
+ if (entitlement.value === "unlimited") {
651
+ entitlementBody.valueType = "unlimited";
652
+ }
653
+ else if (!isNaN(Number(entitlement.value))) {
654
+ entitlementBody.valueType = "numeric";
655
+ entitlementBody.valueNumeric = Number(entitlement.value);
656
+ }
657
+ else {
658
+ throw new Error(`Invalid value "${entitlement.value}" for ${featureType}-based feature "${featureDisplay}". Must be a number or "unlimited".`);
659
+ }
660
+ }
661
+ else {
662
+ throw new Error(`Unsupported feature type "${featureType}" for feature "${featureDisplay}".`);
663
+ }
664
+ await getSchematicClient().entitlements.createPlanEntitlement(entitlementBody);
665
+ const valueDisplay = entitlement.value || (featureType === "boolean" ? "on" : "not provided");
666
+ results.push(`Added ${featureType} entitlement for feature ${featureDisplay}: ${valueDisplay}`);
667
+ }
668
+ return textResponse(results.join("\n"));
669
+ }
670
+ case "list_features": {
671
+ const features = await fetchAll((params) => getSchematicClient().features.listFeatures(params), {});
672
+ if (features.length === 0) {
673
+ return textResponse("No features found.");
674
+ }
675
+ const featureList = features
676
+ .map((feature) => {
677
+ const type = feature.featureType || "unknown";
678
+ return `- ${feature.name} (${feature.id}) - Type: ${type}`;
679
+ })
680
+ .join("\n");
681
+ return textResponse(`Features:\n${featureList}`);
682
+ }
683
+ case "create_feature": {
684
+ const name = requiredStringArg(args, "name");
685
+ const description = stringArg(args, "description");
686
+ const featureTypeArg = stringArg(args, "featureType");
687
+ if (featureTypeArg && !["boolean", "event", "trait"].includes(featureTypeArg)) {
688
+ throw new Error(`Invalid featureType "${featureTypeArg}". Must be "boolean", "event", or "trait".`);
689
+ }
690
+ const featureType = featureTypeArg || "boolean";
691
+ const eventSubtype = stringArg(args, "eventSubtype");
692
+ const planId = stringArg(args, "planId");
693
+ const planName = stringArg(args, "planName");
694
+ // Check capitalization and auto-correct to Title Case
695
+ const properlyCapitalized = isTitleCase(name);
696
+ const suggestedName = toTitleCase(name);
697
+ const finalName = properlyCapitalized ? name : suggestedName;
698
+ // Use empty string if description is not provided
699
+ const finalDescription = description || "";
700
+ // Trait-based features must be created in the web app
701
+ if (featureType === "trait") {
702
+ return textResponse("Trait-based features must be created in the Schematic web app. Please visit https://app.schematichq.com/features to create trait-based features.");
703
+ }
704
+ // Validate event-based feature requirements
705
+ if (featureType === "event" && !eventSubtype) {
706
+ throw new Error("eventSubtype is required for event-based features");
707
+ }
708
+ // Build the create feature request body
709
+ const createFeatureBody = {
710
+ name: finalName,
711
+ description: finalDescription,
712
+ featureType,
713
+ };
714
+ if (featureType === "event" && eventSubtype) {
715
+ createFeatureBody.eventSubtype = eventSubtype;
716
+ }
717
+ const featureResponse = await getSchematicClient().features.createFeature(createFeatureBody);
718
+ const feature = featureResponse.data;
719
+ let result = `Created feature: ${feature.name} (${feature.id})`;
720
+ // Create a flag for the feature
721
+ try {
722
+ const flagKey = generateFlagKey(feature.name);
723
+ const flagResponse = await getSchematicClient().features.createFlag({
724
+ key: flagKey,
725
+ name: feature.name,
726
+ description: finalDescription || `Flag for ${feature.name}`,
727
+ flagType: "boolean",
728
+ defaultValue: false,
729
+ featureId: feature.id,
730
+ });
731
+ const flag = flagResponse.data;
732
+ result += `\nCreated flag: ${flag.name} (key: ${flag.key})`;
733
+ }
734
+ catch (flagError) {
735
+ const flagErrorMessage = flagError instanceof Error ? flagError.message : "Unknown error";
736
+ result += `\n⚠️ Warning: Feature created but flag creation failed: ${flagErrorMessage}`;
737
+ }
738
+ // Add capitalization suggestion if the name was changed
739
+ if (!properlyCapitalized && name !== finalName) {
740
+ result += `\n💡 Note: Feature name was capitalized from "${name}" to "${finalName}"`;
741
+ }
742
+ return textResponse(result);
743
+ }
744
+ default:
745
+ throw new Error(`Unknown tool: ${name}`);
746
+ }
747
+ }
748
+ catch (error) {
749
+ if (error instanceof McpError) {
750
+ throw error;
751
+ }
752
+ const errorMessage = error instanceof Error ? error.message : "An error occurred";
753
+ throw new McpError(ErrorCode.InternalError, errorMessage);
754
+ }
755
+ });
756
+ // Start the server
757
+ async function main() {
758
+ const transport = new StdioServerTransport();
759
+ await server.connect(transport);
760
+ console.error("Schematic MCP server running on stdio");
761
+ }
762
+ main().catch((error) => {
763
+ console.error("Fatal error:", error);
764
+ process.exit(1);
765
+ });
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@schematichq/schematic-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for SchematicHQ - manage companies, plans, features, and billing from any MCP client",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": "dist/index.js",
8
+ "files": [
9
+ "dist",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "dev": "tsx watch src/index.ts",
16
+ "start": "node dist/index.js",
17
+ "test": "tsx test-client.ts",
18
+ "prepublishOnly": "yarn build"
19
+ },
20
+ "keywords": [
21
+ "mcp",
22
+ "model-context-protocol",
23
+ "schematic",
24
+ "schematichq",
25
+ "billing",
26
+ "plans",
27
+ "features",
28
+ "entitlements",
29
+ "feature-flags"
30
+ ],
31
+ "author": "SchematicHQ <support@schematichq.com>",
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/schematichq/schematic-mcp.git"
36
+ },
37
+ "mcpName": "io.github.schematichq/schematic-mcp",
38
+ "homepage": "https://github.com/schematichq/schematic-mcp#readme",
39
+ "bugs": {
40
+ "url": "https://github.com/schematichq/schematic-mcp/issues"
41
+ },
42
+ "engines": {
43
+ "node": ">=18"
44
+ },
45
+ "dependencies": {
46
+ "@modelcontextprotocol/sdk": "^1.0.0",
47
+ "@schematichq/schematic-typescript-node": "^1.0.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^20.10.0",
51
+ "dotenv": "^17.3.1",
52
+ "tsx": "^4.7.0",
53
+ "typescript": "^5.3.3"
54
+ }
55
+ }