clearspec-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/.next/trace +1 -0
- package/.next/trace-build +1 -0
- package/dist/format.d.ts +2 -0
- package/dist/format.js +102 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +73 -0
- package/dist/supabase.d.ts +5 -0
- package/dist/supabase.js +52 -0
- package/dist/tools.d.ts +40 -0
- package/dist/tools.js +108 -0
- package/dist/types.d.ts +76 -0
- package/dist/types.js +39 -0
- package/package.json +23 -0
- package/src/format.ts +115 -0
- package/src/index.ts +108 -0
- package/src/supabase.ts +74 -0
- package/src/tools.ts +138 -0
- package/src/types.ts +133 -0
- package/tsconfig.json +14 -0
package/.next/trace
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[{"name":"generate-buildid","duration":121,"timestamp":813651137740,"id":4,"parentId":1,"tags":{},"startTime":1775774788522,"traceId":"26d8c2e24f9254f6"},{"name":"load-custom-routes","duration":881,"timestamp":813651137915,"id":5,"parentId":1,"tags":{},"startTime":1775774788522,"traceId":"26d8c2e24f9254f6"},{"name":"create-dist-dir","duration":1514,"timestamp":813651138809,"id":6,"parentId":1,"tags":{},"startTime":1775774788523,"traceId":"26d8c2e24f9254f6"},{"name":"clean","duration":694,"timestamp":813651140889,"id":7,"parentId":1,"tags":{},"startTime":1775774788525,"traceId":"26d8c2e24f9254f6"},{"name":"next-build","duration":75245,"timestamp":813651066423,"id":1,"tags":{"buildMode":"default","version":"16.2.2","bundler":"turbopack","failed":true},"startTime":1775774788450,"traceId":"26d8c2e24f9254f6"}]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[{"name":"next-build","duration":75245,"timestamp":813651066423,"id":1,"tags":{"buildMode":"default","version":"16.2.2","bundler":"turbopack","failed":true},"startTime":1775774788450,"traceId":"26d8c2e24f9254f6"}]
|
package/dist/format.d.ts
ADDED
package/dist/format.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
function statusLabel(status) {
|
|
2
|
+
const labels = {
|
|
3
|
+
ready_to_build: "Ready to build",
|
|
4
|
+
in_progress: "In progress",
|
|
5
|
+
draft: "Draft",
|
|
6
|
+
archived: "Archived",
|
|
7
|
+
in_review: "In review",
|
|
8
|
+
approved: "Approved",
|
|
9
|
+
in_development: "In development",
|
|
10
|
+
delivered: "Delivered",
|
|
11
|
+
};
|
|
12
|
+
return labels[status] ?? status;
|
|
13
|
+
}
|
|
14
|
+
export function formatSpecAsMarkdown(spec) {
|
|
15
|
+
const { sections } = spec;
|
|
16
|
+
const lines = [];
|
|
17
|
+
lines.push(`# ${spec.title}`);
|
|
18
|
+
lines.push("");
|
|
19
|
+
lines.push(`**Status:** ${statusLabel(spec.status)} | **Completeness:** ${spec.completenessScore}%`);
|
|
20
|
+
lines.push(`**Updated:** ${spec.updatedAt}`);
|
|
21
|
+
lines.push("");
|
|
22
|
+
// Goal
|
|
23
|
+
if (sections.goal?.trim()) {
|
|
24
|
+
lines.push("## Goal");
|
|
25
|
+
lines.push("");
|
|
26
|
+
lines.push(sections.goal.trim());
|
|
27
|
+
lines.push("");
|
|
28
|
+
}
|
|
29
|
+
// User Stories
|
|
30
|
+
if (sections.userStories?.length > 0) {
|
|
31
|
+
lines.push("## User Stories");
|
|
32
|
+
lines.push("");
|
|
33
|
+
for (const story of sections.userStories) {
|
|
34
|
+
lines.push(`### As a ${story.persona}, I want to ${story.action}, so that ${story.benefit}`);
|
|
35
|
+
if (story.acceptanceCriteria?.length > 0) {
|
|
36
|
+
lines.push("");
|
|
37
|
+
lines.push("**Acceptance Criteria:**");
|
|
38
|
+
for (const criterion of story.acceptanceCriteria) {
|
|
39
|
+
lines.push(`- ${criterion}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
lines.push("");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// Global Acceptance Criteria
|
|
46
|
+
if (sections.acceptanceCriteria?.length > 0) {
|
|
47
|
+
lines.push("## Acceptance Criteria");
|
|
48
|
+
lines.push("");
|
|
49
|
+
for (const criterion of sections.acceptanceCriteria) {
|
|
50
|
+
lines.push(`- ${criterion}`);
|
|
51
|
+
}
|
|
52
|
+
lines.push("");
|
|
53
|
+
}
|
|
54
|
+
// Edge Cases
|
|
55
|
+
if (sections.edgeCases?.length > 0) {
|
|
56
|
+
lines.push("## Edge Cases");
|
|
57
|
+
lines.push("");
|
|
58
|
+
for (const edge of sections.edgeCases) {
|
|
59
|
+
lines.push(`- **${edge.description}** — ${edge.resolution}`);
|
|
60
|
+
}
|
|
61
|
+
lines.push("");
|
|
62
|
+
}
|
|
63
|
+
// Failure States
|
|
64
|
+
if (sections.failureStates?.length > 0) {
|
|
65
|
+
lines.push("## Failure States");
|
|
66
|
+
lines.push("");
|
|
67
|
+
for (const failure of sections.failureStates) {
|
|
68
|
+
lines.push(`- **Trigger:** ${failure.trigger}`);
|
|
69
|
+
lines.push(` - **Behavior:** ${failure.behavior}`);
|
|
70
|
+
lines.push(` - **Recovery:** ${failure.recovery}`);
|
|
71
|
+
}
|
|
72
|
+
lines.push("");
|
|
73
|
+
}
|
|
74
|
+
// Dependencies
|
|
75
|
+
if (sections.dependencies?.length > 0) {
|
|
76
|
+
lines.push("## Dependencies");
|
|
77
|
+
lines.push("");
|
|
78
|
+
for (const dep of sections.dependencies) {
|
|
79
|
+
lines.push(`- **${dep.name}** — ${dep.description}`);
|
|
80
|
+
}
|
|
81
|
+
lines.push("");
|
|
82
|
+
}
|
|
83
|
+
// Out of Scope
|
|
84
|
+
if (sections.outOfScope?.length > 0) {
|
|
85
|
+
lines.push("## Out of Scope");
|
|
86
|
+
lines.push("");
|
|
87
|
+
for (const item of sections.outOfScope) {
|
|
88
|
+
lines.push(`- ${item}`);
|
|
89
|
+
}
|
|
90
|
+
lines.push("");
|
|
91
|
+
}
|
|
92
|
+
// Verification Criteria
|
|
93
|
+
if (sections.verificationCriteria?.length > 0) {
|
|
94
|
+
lines.push("## Verification Criteria");
|
|
95
|
+
lines.push("");
|
|
96
|
+
for (const vc of sections.verificationCriteria) {
|
|
97
|
+
lines.push(`- [${vc.type}] ${vc.description}`);
|
|
98
|
+
}
|
|
99
|
+
lines.push("");
|
|
100
|
+
}
|
|
101
|
+
return lines.join("\n");
|
|
102
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { validateAuth } from "./supabase.js";
|
|
5
|
+
import { listSpecs, getSpec, searchSpecs, getSpecAsMarkdown, listSpecsSchema, getSpecSchema, searchSpecsSchema, getSpecMarkdownSchema, } from "./tools.js";
|
|
6
|
+
const server = new McpServer({
|
|
7
|
+
name: "clearspec",
|
|
8
|
+
version: "0.1.0",
|
|
9
|
+
});
|
|
10
|
+
// --- Tool registrations ---
|
|
11
|
+
server.tool("list_specs", "List all your specs with title, status, and completeness score. Returns metadata only — use get_spec for full content.", listSpecsSchema.shape, async ({ status, project_id }) => {
|
|
12
|
+
try {
|
|
13
|
+
const specs = await listSpecs({ status, project_id });
|
|
14
|
+
return {
|
|
15
|
+
content: [{ type: "text", text: JSON.stringify(specs, null, 2) }],
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
20
|
+
return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
server.tool("get_spec", "Get a full spec with all sections (goal, user stories, edge cases, etc.). Use include_versions to also fetch version history.", getSpecSchema.shape, async ({ spec_id, include_versions }) => {
|
|
24
|
+
try {
|
|
25
|
+
const spec = await getSpec({ spec_id, include_versions });
|
|
26
|
+
if (!spec) {
|
|
27
|
+
return { content: [{ type: "text", text: `Spec not found: ${spec_id}` }], isError: true };
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
content: [{ type: "text", text: JSON.stringify(spec, null, 2) }],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
35
|
+
return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
server.tool("search_specs", "Search specs by title keyword. Returns metadata only — use get_spec for full content.", searchSpecsSchema.shape, async ({ query }) => {
|
|
39
|
+
try {
|
|
40
|
+
const specs = await searchSpecs({ query });
|
|
41
|
+
return {
|
|
42
|
+
content: [{ type: "text", text: JSON.stringify(specs, null, 2) }],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
47
|
+
return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
server.tool("get_spec_as_markdown", "Get a spec formatted as implementation-ready markdown with all sections: goal, user stories (with acceptance criteria), edge cases, failure states, dependencies, out of scope, and verification criteria.", getSpecMarkdownSchema.shape, async ({ spec_id }) => {
|
|
51
|
+
try {
|
|
52
|
+
const markdown = await getSpecAsMarkdown({ spec_id });
|
|
53
|
+
return {
|
|
54
|
+
content: [{ type: "text", text: markdown }],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
59
|
+
return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
// --- Start server ---
|
|
63
|
+
async function main() {
|
|
64
|
+
// Validate auth before accepting connections
|
|
65
|
+
await validateAuth();
|
|
66
|
+
const transport = new StdioServerTransport();
|
|
67
|
+
await server.connect(transport);
|
|
68
|
+
}
|
|
69
|
+
main().catch((error) => {
|
|
70
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
71
|
+
process.stderr.write(`clearspec-mcp: ${message}\n`);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type SupabaseClient } from "@supabase/supabase-js";
|
|
2
|
+
export declare function createClient(): SupabaseClient;
|
|
3
|
+
/** Validate the token at startup and cache the user ID. */
|
|
4
|
+
export declare function validateAuth(): Promise<string>;
|
|
5
|
+
export declare function getCachedUserId(): string;
|
package/dist/supabase.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// SYNC: Auth pattern differs from src/lib/supabase/server.ts — uses header injection, not cookies
|
|
2
|
+
import { createClient as createSupabaseClient } from "@supabase/supabase-js";
|
|
3
|
+
// Default Supabase project — these are public values (same as the frontend)
|
|
4
|
+
const DEFAULT_SUPABASE_URL = "https://adyjdccjwnrkzfzctfgo.supabase.co";
|
|
5
|
+
const DEFAULT_SUPABASE_ANON_KEY = "sb_publishable_U1PJ3J1I-pJaSqKm4sBwyA_LUimFh44";
|
|
6
|
+
function getEnvConfig() {
|
|
7
|
+
const supabaseUrl = process.env.CLEARSPEC_SUPABASE_URL || DEFAULT_SUPABASE_URL;
|
|
8
|
+
const supabaseAnonKey = process.env.CLEARSPEC_SUPABASE_ANON_KEY || DEFAULT_SUPABASE_ANON_KEY;
|
|
9
|
+
const accessToken = process.env.CLEARSPEC_ACCESS_TOKEN;
|
|
10
|
+
if (!accessToken) {
|
|
11
|
+
throw new Error("Missing CLEARSPEC_ACCESS_TOKEN. Get one from ClearSpec Settings > MCP Integration.");
|
|
12
|
+
}
|
|
13
|
+
return { supabaseUrl, supabaseAnonKey, accessToken };
|
|
14
|
+
}
|
|
15
|
+
let cachedClient = null;
|
|
16
|
+
let cachedUserId = null;
|
|
17
|
+
export function createClient() {
|
|
18
|
+
if (cachedClient)
|
|
19
|
+
return cachedClient;
|
|
20
|
+
const { supabaseUrl, supabaseAnonKey, accessToken } = getEnvConfig();
|
|
21
|
+
cachedClient = createSupabaseClient(supabaseUrl, supabaseAnonKey, {
|
|
22
|
+
global: {
|
|
23
|
+
headers: {
|
|
24
|
+
Authorization: `Bearer ${accessToken}`,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
auth: {
|
|
28
|
+
persistSession: false,
|
|
29
|
+
autoRefreshToken: false,
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
return cachedClient;
|
|
33
|
+
}
|
|
34
|
+
/** Validate the token at startup and cache the user ID. */
|
|
35
|
+
export async function validateAuth() {
|
|
36
|
+
if (cachedUserId)
|
|
37
|
+
return cachedUserId;
|
|
38
|
+
const supabase = createClient();
|
|
39
|
+
const { data, error } = await supabase.auth.getUser();
|
|
40
|
+
if (error || !data.user) {
|
|
41
|
+
throw new Error("Authentication failed. Your CLEARSPEC_ACCESS_TOKEN may be invalid or expired. " +
|
|
42
|
+
"Get a fresh token from ClearSpec Settings and restart the MCP server.");
|
|
43
|
+
}
|
|
44
|
+
cachedUserId = data.user.id;
|
|
45
|
+
return cachedUserId;
|
|
46
|
+
}
|
|
47
|
+
export function getCachedUserId() {
|
|
48
|
+
if (!cachedUserId) {
|
|
49
|
+
throw new Error("Auth not validated yet. Call validateAuth() first.");
|
|
50
|
+
}
|
|
51
|
+
return cachedUserId;
|
|
52
|
+
}
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Spec, SpecMeta } from "./types.js";
|
|
3
|
+
export declare const listSpecsSchema: z.ZodObject<{
|
|
4
|
+
status: z.ZodOptional<z.ZodString>;
|
|
5
|
+
project_id: z.ZodOptional<z.ZodString>;
|
|
6
|
+
}, "strip", z.ZodTypeAny, {
|
|
7
|
+
status?: string | undefined;
|
|
8
|
+
project_id?: string | undefined;
|
|
9
|
+
}, {
|
|
10
|
+
status?: string | undefined;
|
|
11
|
+
project_id?: string | undefined;
|
|
12
|
+
}>;
|
|
13
|
+
export declare const getSpecSchema: z.ZodObject<{
|
|
14
|
+
spec_id: z.ZodString;
|
|
15
|
+
include_versions: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
|
16
|
+
}, "strip", z.ZodTypeAny, {
|
|
17
|
+
spec_id: string;
|
|
18
|
+
include_versions: boolean;
|
|
19
|
+
}, {
|
|
20
|
+
spec_id: string;
|
|
21
|
+
include_versions?: boolean | undefined;
|
|
22
|
+
}>;
|
|
23
|
+
export declare const searchSpecsSchema: z.ZodObject<{
|
|
24
|
+
query: z.ZodString;
|
|
25
|
+
}, "strip", z.ZodTypeAny, {
|
|
26
|
+
query: string;
|
|
27
|
+
}, {
|
|
28
|
+
query: string;
|
|
29
|
+
}>;
|
|
30
|
+
export declare const getSpecMarkdownSchema: z.ZodObject<{
|
|
31
|
+
spec_id: z.ZodString;
|
|
32
|
+
}, "strip", z.ZodTypeAny, {
|
|
33
|
+
spec_id: string;
|
|
34
|
+
}, {
|
|
35
|
+
spec_id: string;
|
|
36
|
+
}>;
|
|
37
|
+
export declare function listSpecs(input: z.infer<typeof listSpecsSchema>): Promise<SpecMeta[]>;
|
|
38
|
+
export declare function getSpec(input: z.infer<typeof getSpecSchema>): Promise<Spec | null>;
|
|
39
|
+
export declare function searchSpecs(input: z.infer<typeof searchSpecsSchema>): Promise<SpecMeta[]>;
|
|
40
|
+
export declare function getSpecAsMarkdown(input: z.infer<typeof getSpecMarkdownSchema>): Promise<string>;
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { createClient, getCachedUserId } from "./supabase.js";
|
|
3
|
+
import { rowToSpec, rowToSpecMeta } from "./types.js";
|
|
4
|
+
import { formatSpecAsMarkdown } from "./format.js";
|
|
5
|
+
const AUTH_ERROR_MSG = "Authentication failed. Your token may have expired. " +
|
|
6
|
+
"Get a fresh token from ClearSpec Settings and restart the MCP server.";
|
|
7
|
+
/** Check if an error is a Supabase auth error (expired/invalid token). */
|
|
8
|
+
function isAuthError(error) {
|
|
9
|
+
if (error && typeof error === "object" && "code" in error) {
|
|
10
|
+
const code = error.code;
|
|
11
|
+
return code === "PGRST301" || code === "401" || code === "403";
|
|
12
|
+
}
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
// --- Tool input schemas ---
|
|
16
|
+
export const listSpecsSchema = z.object({
|
|
17
|
+
status: z.string().optional().describe("Filter by status (e.g. 'draft', 'ready_to_build', 'in_progress')"),
|
|
18
|
+
project_id: z.string().optional().describe("Filter by project ID"),
|
|
19
|
+
});
|
|
20
|
+
export const getSpecSchema = z.object({
|
|
21
|
+
spec_id: z.string().describe("The spec ID to retrieve"),
|
|
22
|
+
include_versions: z.boolean().optional().default(false).describe("Include version history (default: false)"),
|
|
23
|
+
});
|
|
24
|
+
export const searchSpecsSchema = z.object({
|
|
25
|
+
query: z.string().describe("Search term to match against spec titles"),
|
|
26
|
+
});
|
|
27
|
+
export const getSpecMarkdownSchema = z.object({
|
|
28
|
+
spec_id: z.string().describe("The spec ID to format as markdown"),
|
|
29
|
+
});
|
|
30
|
+
// --- Tool handlers ---
|
|
31
|
+
export async function listSpecs(input) {
|
|
32
|
+
const supabase = createClient();
|
|
33
|
+
const userId = getCachedUserId();
|
|
34
|
+
let query = supabase
|
|
35
|
+
.from("specs")
|
|
36
|
+
.select("id, title, status, completeness_score, project_id, updated_at")
|
|
37
|
+
.eq("user_id", userId)
|
|
38
|
+
.order("updated_at", { ascending: false });
|
|
39
|
+
if (input.status) {
|
|
40
|
+
query = query.eq("status", input.status);
|
|
41
|
+
}
|
|
42
|
+
if (input.project_id) {
|
|
43
|
+
query = query.eq("project_id", input.project_id);
|
|
44
|
+
}
|
|
45
|
+
const { data, error } = await query;
|
|
46
|
+
if (error) {
|
|
47
|
+
if (isAuthError(error))
|
|
48
|
+
throw new Error(AUTH_ERROR_MSG);
|
|
49
|
+
throw new Error(`Failed to list specs: ${error.message}`);
|
|
50
|
+
}
|
|
51
|
+
return (data || []).map((row) => rowToSpecMeta(row));
|
|
52
|
+
}
|
|
53
|
+
export async function getSpec(input) {
|
|
54
|
+
const supabase = createClient();
|
|
55
|
+
const userId = getCachedUserId();
|
|
56
|
+
const specPromise = supabase
|
|
57
|
+
.from("specs")
|
|
58
|
+
.select("*")
|
|
59
|
+
.eq("id", input.spec_id)
|
|
60
|
+
.eq("user_id", userId)
|
|
61
|
+
.single();
|
|
62
|
+
if (input.include_versions) {
|
|
63
|
+
const [specResult, versionsResult] = await Promise.all([
|
|
64
|
+
specPromise,
|
|
65
|
+
supabase
|
|
66
|
+
.from("spec_versions")
|
|
67
|
+
.select("*")
|
|
68
|
+
.eq("spec_id", input.spec_id)
|
|
69
|
+
.order("version", { ascending: true }),
|
|
70
|
+
]);
|
|
71
|
+
if (specResult.error) {
|
|
72
|
+
if (isAuthError(specResult.error))
|
|
73
|
+
throw new Error(AUTH_ERROR_MSG);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return rowToSpec(specResult.data, (versionsResult.data || []));
|
|
77
|
+
}
|
|
78
|
+
const { data, error } = await specPromise;
|
|
79
|
+
if (error) {
|
|
80
|
+
if (isAuthError(error))
|
|
81
|
+
throw new Error(AUTH_ERROR_MSG);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
return rowToSpec(data);
|
|
85
|
+
}
|
|
86
|
+
export async function searchSpecs(input) {
|
|
87
|
+
const supabase = createClient();
|
|
88
|
+
const userId = getCachedUserId();
|
|
89
|
+
const { data, error } = await supabase
|
|
90
|
+
.from("specs")
|
|
91
|
+
.select("id, title, status, completeness_score, project_id, updated_at")
|
|
92
|
+
.eq("user_id", userId)
|
|
93
|
+
.ilike("title", `%${input.query}%`)
|
|
94
|
+
.order("updated_at", { ascending: false });
|
|
95
|
+
if (error) {
|
|
96
|
+
if (isAuthError(error))
|
|
97
|
+
throw new Error(AUTH_ERROR_MSG);
|
|
98
|
+
throw new Error(`Failed to search specs: ${error.message}`);
|
|
99
|
+
}
|
|
100
|
+
return (data || []).map((row) => rowToSpecMeta(row));
|
|
101
|
+
}
|
|
102
|
+
export async function getSpecAsMarkdown(input) {
|
|
103
|
+
const spec = await getSpec({ spec_id: input.spec_id, include_versions: false });
|
|
104
|
+
if (!spec) {
|
|
105
|
+
throw new Error(`Spec not found: ${input.spec_id}`);
|
|
106
|
+
}
|
|
107
|
+
return formatSpecAsMarkdown(spec);
|
|
108
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export interface UserStory {
|
|
2
|
+
id: string;
|
|
3
|
+
persona: string;
|
|
4
|
+
action: string;
|
|
5
|
+
benefit: string;
|
|
6
|
+
acceptanceCriteria: string[];
|
|
7
|
+
}
|
|
8
|
+
export interface EdgeCase {
|
|
9
|
+
id: string;
|
|
10
|
+
description: string;
|
|
11
|
+
resolution: string;
|
|
12
|
+
}
|
|
13
|
+
export interface FailureState {
|
|
14
|
+
id: string;
|
|
15
|
+
trigger: string;
|
|
16
|
+
behavior: string;
|
|
17
|
+
recovery: string;
|
|
18
|
+
}
|
|
19
|
+
export interface Dependency {
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
description: string;
|
|
23
|
+
}
|
|
24
|
+
export interface VerificationCriterion {
|
|
25
|
+
id: string;
|
|
26
|
+
description: string;
|
|
27
|
+
type: "automated" | "manual";
|
|
28
|
+
}
|
|
29
|
+
export interface SpecSection {
|
|
30
|
+
goal: string;
|
|
31
|
+
userStories: UserStory[];
|
|
32
|
+
acceptanceCriteria: string[];
|
|
33
|
+
edgeCases: EdgeCase[];
|
|
34
|
+
failureStates: FailureState[];
|
|
35
|
+
dependencies: Dependency[];
|
|
36
|
+
outOfScope: string[];
|
|
37
|
+
verificationCriteria: VerificationCriterion[];
|
|
38
|
+
}
|
|
39
|
+
export type SpecStatus = "draft" | "in_review" | "approved" | "in_development" | "delivered" | "in_progress" | "ready_to_build" | "archived";
|
|
40
|
+
export interface Spec {
|
|
41
|
+
id: string;
|
|
42
|
+
title: string;
|
|
43
|
+
status: SpecStatus;
|
|
44
|
+
completenessScore: number;
|
|
45
|
+
sections: SpecSection;
|
|
46
|
+
projectId: string | null;
|
|
47
|
+
isSample: boolean;
|
|
48
|
+
shareToken: string | null;
|
|
49
|
+
sharePermission: "view" | "comment" | "edit";
|
|
50
|
+
repoFullName: string | null;
|
|
51
|
+
linearIssueId: string | null;
|
|
52
|
+
linearIssueUrl: string | null;
|
|
53
|
+
createdAt: string;
|
|
54
|
+
updatedAt: string;
|
|
55
|
+
versions: SpecVersion[];
|
|
56
|
+
}
|
|
57
|
+
export interface SpecVersion {
|
|
58
|
+
id: string;
|
|
59
|
+
specId: string;
|
|
60
|
+
version: number;
|
|
61
|
+
sections: SpecSection;
|
|
62
|
+
changeNote: string | null;
|
|
63
|
+
changedById: string | null;
|
|
64
|
+
createdAt: string;
|
|
65
|
+
}
|
|
66
|
+
/** Metadata-only shape for list_specs (no sections/versions). */
|
|
67
|
+
export interface SpecMeta {
|
|
68
|
+
id: string;
|
|
69
|
+
title: string;
|
|
70
|
+
status: SpecStatus;
|
|
71
|
+
completenessScore: number;
|
|
72
|
+
projectId: string | null;
|
|
73
|
+
updatedAt: string;
|
|
74
|
+
}
|
|
75
|
+
export declare function rowToSpec(row: Record<string, unknown>, versions?: Record<string, unknown>[]): Spec;
|
|
76
|
+
export declare function rowToSpecMeta(row: Record<string, unknown>): SpecMeta;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SYNC: copied from src/types/spec.ts — keep in sync when spec schema changes
|
|
2
|
+
// SYNC: copied from src/lib/store/specs.ts rowToSpec()
|
|
3
|
+
export function rowToSpec(row, versions = []) {
|
|
4
|
+
return {
|
|
5
|
+
id: row.id,
|
|
6
|
+
title: row.title,
|
|
7
|
+
status: row.status,
|
|
8
|
+
completenessScore: row.completeness_score,
|
|
9
|
+
sections: row.sections,
|
|
10
|
+
projectId: row.project_id || null,
|
|
11
|
+
isSample: row.is_sample ?? false,
|
|
12
|
+
shareToken: row.share_token ?? null,
|
|
13
|
+
sharePermission: row.share_permission ?? "view",
|
|
14
|
+
repoFullName: row.repo_full_name ?? null,
|
|
15
|
+
linearIssueId: row.linear_issue_id ?? null,
|
|
16
|
+
linearIssueUrl: row.linear_issue_url ?? null,
|
|
17
|
+
createdAt: row.created_at,
|
|
18
|
+
updatedAt: row.updated_at,
|
|
19
|
+
versions: versions.map((v) => ({
|
|
20
|
+
id: v.id,
|
|
21
|
+
specId: v.spec_id,
|
|
22
|
+
version: v.version,
|
|
23
|
+
sections: v.sections,
|
|
24
|
+
changeNote: v.change_note ?? null,
|
|
25
|
+
changedById: v.changed_by_id ?? null,
|
|
26
|
+
createdAt: v.created_at,
|
|
27
|
+
})),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function rowToSpecMeta(row) {
|
|
31
|
+
return {
|
|
32
|
+
id: row.id,
|
|
33
|
+
title: row.title,
|
|
34
|
+
status: row.status,
|
|
35
|
+
completenessScore: row.completeness_score,
|
|
36
|
+
projectId: row.project_id || null,
|
|
37
|
+
updatedAt: row.updated_at,
|
|
38
|
+
};
|
|
39
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "clearspec-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for reading ClearSpec specs from Claude Code",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"clearspec-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"dev": "tsc --watch",
|
|
13
|
+
"start": "node dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
17
|
+
"@supabase/supabase-js": "^2.101.1",
|
|
18
|
+
"zod": "^3.23.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"typescript": "^5.7.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { Spec, SpecSection } from "./types.js";
|
|
2
|
+
|
|
3
|
+
function statusLabel(status: string): string {
|
|
4
|
+
const labels: Record<string, string> = {
|
|
5
|
+
ready_to_build: "Ready to build",
|
|
6
|
+
in_progress: "In progress",
|
|
7
|
+
draft: "Draft",
|
|
8
|
+
archived: "Archived",
|
|
9
|
+
in_review: "In review",
|
|
10
|
+
approved: "Approved",
|
|
11
|
+
in_development: "In development",
|
|
12
|
+
delivered: "Delivered",
|
|
13
|
+
};
|
|
14
|
+
return labels[status] ?? status;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function formatSpecAsMarkdown(spec: Spec): string {
|
|
18
|
+
const { sections } = spec;
|
|
19
|
+
const lines: string[] = [];
|
|
20
|
+
|
|
21
|
+
lines.push(`# ${spec.title}`);
|
|
22
|
+
lines.push("");
|
|
23
|
+
lines.push(`**Status:** ${statusLabel(spec.status)} | **Completeness:** ${spec.completenessScore}%`);
|
|
24
|
+
lines.push(`**Updated:** ${spec.updatedAt}`);
|
|
25
|
+
lines.push("");
|
|
26
|
+
|
|
27
|
+
// Goal
|
|
28
|
+
if (sections.goal?.trim()) {
|
|
29
|
+
lines.push("## Goal");
|
|
30
|
+
lines.push("");
|
|
31
|
+
lines.push(sections.goal.trim());
|
|
32
|
+
lines.push("");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// User Stories
|
|
36
|
+
if (sections.userStories?.length > 0) {
|
|
37
|
+
lines.push("## User Stories");
|
|
38
|
+
lines.push("");
|
|
39
|
+
for (const story of sections.userStories) {
|
|
40
|
+
lines.push(`### As a ${story.persona}, I want to ${story.action}, so that ${story.benefit}`);
|
|
41
|
+
if (story.acceptanceCriteria?.length > 0) {
|
|
42
|
+
lines.push("");
|
|
43
|
+
lines.push("**Acceptance Criteria:**");
|
|
44
|
+
for (const criterion of story.acceptanceCriteria) {
|
|
45
|
+
lines.push(`- ${criterion}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
lines.push("");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Global Acceptance Criteria
|
|
53
|
+
if (sections.acceptanceCriteria?.length > 0) {
|
|
54
|
+
lines.push("## Acceptance Criteria");
|
|
55
|
+
lines.push("");
|
|
56
|
+
for (const criterion of sections.acceptanceCriteria) {
|
|
57
|
+
lines.push(`- ${criterion}`);
|
|
58
|
+
}
|
|
59
|
+
lines.push("");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Edge Cases
|
|
63
|
+
if (sections.edgeCases?.length > 0) {
|
|
64
|
+
lines.push("## Edge Cases");
|
|
65
|
+
lines.push("");
|
|
66
|
+
for (const edge of sections.edgeCases) {
|
|
67
|
+
lines.push(`- **${edge.description}** — ${edge.resolution}`);
|
|
68
|
+
}
|
|
69
|
+
lines.push("");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Failure States
|
|
73
|
+
if (sections.failureStates?.length > 0) {
|
|
74
|
+
lines.push("## Failure States");
|
|
75
|
+
lines.push("");
|
|
76
|
+
for (const failure of sections.failureStates) {
|
|
77
|
+
lines.push(`- **Trigger:** ${failure.trigger}`);
|
|
78
|
+
lines.push(` - **Behavior:** ${failure.behavior}`);
|
|
79
|
+
lines.push(` - **Recovery:** ${failure.recovery}`);
|
|
80
|
+
}
|
|
81
|
+
lines.push("");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Dependencies
|
|
85
|
+
if (sections.dependencies?.length > 0) {
|
|
86
|
+
lines.push("## Dependencies");
|
|
87
|
+
lines.push("");
|
|
88
|
+
for (const dep of sections.dependencies) {
|
|
89
|
+
lines.push(`- **${dep.name}** — ${dep.description}`);
|
|
90
|
+
}
|
|
91
|
+
lines.push("");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Out of Scope
|
|
95
|
+
if (sections.outOfScope?.length > 0) {
|
|
96
|
+
lines.push("## Out of Scope");
|
|
97
|
+
lines.push("");
|
|
98
|
+
for (const item of sections.outOfScope) {
|
|
99
|
+
lines.push(`- ${item}`);
|
|
100
|
+
}
|
|
101
|
+
lines.push("");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Verification Criteria
|
|
105
|
+
if (sections.verificationCriteria?.length > 0) {
|
|
106
|
+
lines.push("## Verification Criteria");
|
|
107
|
+
lines.push("");
|
|
108
|
+
for (const vc of sections.verificationCriteria) {
|
|
109
|
+
lines.push(`- [${vc.type}] ${vc.description}`);
|
|
110
|
+
}
|
|
111
|
+
lines.push("");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return lines.join("\n");
|
|
115
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { validateAuth } from "./supabase.js";
|
|
5
|
+
import {
|
|
6
|
+
listSpecs,
|
|
7
|
+
getSpec,
|
|
8
|
+
searchSpecs,
|
|
9
|
+
getSpecAsMarkdown,
|
|
10
|
+
listSpecsSchema,
|
|
11
|
+
getSpecSchema,
|
|
12
|
+
searchSpecsSchema,
|
|
13
|
+
getSpecMarkdownSchema,
|
|
14
|
+
} from "./tools.js";
|
|
15
|
+
|
|
16
|
+
const server = new McpServer({
|
|
17
|
+
name: "clearspec",
|
|
18
|
+
version: "0.1.0",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// --- Tool registrations ---
|
|
22
|
+
|
|
23
|
+
server.tool(
|
|
24
|
+
"list_specs",
|
|
25
|
+
"List all your specs with title, status, and completeness score. Returns metadata only — use get_spec for full content.",
|
|
26
|
+
listSpecsSchema.shape,
|
|
27
|
+
async ({ status, project_id }) => {
|
|
28
|
+
try {
|
|
29
|
+
const specs = await listSpecs({ status, project_id });
|
|
30
|
+
return {
|
|
31
|
+
content: [{ type: "text" as const, text: JSON.stringify(specs, null, 2) }],
|
|
32
|
+
};
|
|
33
|
+
} catch (error: unknown) {
|
|
34
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
35
|
+
return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
server.tool(
|
|
41
|
+
"get_spec",
|
|
42
|
+
"Get a full spec with all sections (goal, user stories, edge cases, etc.). Use include_versions to also fetch version history.",
|
|
43
|
+
getSpecSchema.shape,
|
|
44
|
+
async ({ spec_id, include_versions }) => {
|
|
45
|
+
try {
|
|
46
|
+
const spec = await getSpec({ spec_id, include_versions });
|
|
47
|
+
if (!spec) {
|
|
48
|
+
return { content: [{ type: "text" as const, text: `Spec not found: ${spec_id}` }], isError: true };
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
content: [{ type: "text" as const, text: JSON.stringify(spec, null, 2) }],
|
|
52
|
+
};
|
|
53
|
+
} catch (error: unknown) {
|
|
54
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
55
|
+
return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
server.tool(
|
|
61
|
+
"search_specs",
|
|
62
|
+
"Search specs by title keyword. Returns metadata only — use get_spec for full content.",
|
|
63
|
+
searchSpecsSchema.shape,
|
|
64
|
+
async ({ query }) => {
|
|
65
|
+
try {
|
|
66
|
+
const specs = await searchSpecs({ query });
|
|
67
|
+
return {
|
|
68
|
+
content: [{ type: "text" as const, text: JSON.stringify(specs, null, 2) }],
|
|
69
|
+
};
|
|
70
|
+
} catch (error: unknown) {
|
|
71
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
72
|
+
return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
server.tool(
|
|
78
|
+
"get_spec_as_markdown",
|
|
79
|
+
"Get a spec formatted as implementation-ready markdown with all sections: goal, user stories (with acceptance criteria), edge cases, failure states, dependencies, out of scope, and verification criteria.",
|
|
80
|
+
getSpecMarkdownSchema.shape,
|
|
81
|
+
async ({ spec_id }) => {
|
|
82
|
+
try {
|
|
83
|
+
const markdown = await getSpecAsMarkdown({ spec_id });
|
|
84
|
+
return {
|
|
85
|
+
content: [{ type: "text" as const, text: markdown }],
|
|
86
|
+
};
|
|
87
|
+
} catch (error: unknown) {
|
|
88
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
89
|
+
return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// --- Start server ---
|
|
95
|
+
|
|
96
|
+
async function main(): Promise<void> {
|
|
97
|
+
// Validate auth before accepting connections
|
|
98
|
+
await validateAuth();
|
|
99
|
+
|
|
100
|
+
const transport = new StdioServerTransport();
|
|
101
|
+
await server.connect(transport);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
main().catch((error: unknown) => {
|
|
105
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
106
|
+
process.stderr.write(`clearspec-mcp: ${message}\n`);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
});
|
package/src/supabase.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// SYNC: Auth pattern differs from src/lib/supabase/server.ts — uses header injection, not cookies
|
|
2
|
+
import { createClient as createSupabaseClient, type SupabaseClient } from "@supabase/supabase-js";
|
|
3
|
+
|
|
4
|
+
// Default Supabase project — these are public values (same as the frontend)
|
|
5
|
+
const DEFAULT_SUPABASE_URL = "https://adyjdccjwnrkzfzctfgo.supabase.co";
|
|
6
|
+
const DEFAULT_SUPABASE_ANON_KEY = "sb_publishable_U1PJ3J1I-pJaSqKm4sBwyA_LUimFh44";
|
|
7
|
+
|
|
8
|
+
interface EnvConfig {
|
|
9
|
+
supabaseUrl: string;
|
|
10
|
+
supabaseAnonKey: string;
|
|
11
|
+
accessToken: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getEnvConfig(): EnvConfig {
|
|
15
|
+
const supabaseUrl = process.env.CLEARSPEC_SUPABASE_URL || DEFAULT_SUPABASE_URL;
|
|
16
|
+
const supabaseAnonKey = process.env.CLEARSPEC_SUPABASE_ANON_KEY || DEFAULT_SUPABASE_ANON_KEY;
|
|
17
|
+
const accessToken = process.env.CLEARSPEC_ACCESS_TOKEN;
|
|
18
|
+
|
|
19
|
+
if (!accessToken) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
"Missing CLEARSPEC_ACCESS_TOKEN. Get one from ClearSpec Settings > MCP Integration."
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return { supabaseUrl, supabaseAnonKey, accessToken };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let cachedClient: SupabaseClient | null = null;
|
|
29
|
+
let cachedUserId: string | null = null;
|
|
30
|
+
|
|
31
|
+
export function createClient(): SupabaseClient {
|
|
32
|
+
if (cachedClient) return cachedClient;
|
|
33
|
+
|
|
34
|
+
const { supabaseUrl, supabaseAnonKey, accessToken } = getEnvConfig();
|
|
35
|
+
|
|
36
|
+
cachedClient = createSupabaseClient(supabaseUrl, supabaseAnonKey, {
|
|
37
|
+
global: {
|
|
38
|
+
headers: {
|
|
39
|
+
Authorization: `Bearer ${accessToken}`,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
auth: {
|
|
43
|
+
persistSession: false,
|
|
44
|
+
autoRefreshToken: false,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return cachedClient;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Validate the token at startup and cache the user ID. */
|
|
52
|
+
export async function validateAuth(): Promise<string> {
|
|
53
|
+
if (cachedUserId) return cachedUserId;
|
|
54
|
+
|
|
55
|
+
const supabase = createClient();
|
|
56
|
+
const { data, error } = await supabase.auth.getUser();
|
|
57
|
+
|
|
58
|
+
if (error || !data.user) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
"Authentication failed. Your CLEARSPEC_ACCESS_TOKEN may be invalid or expired. " +
|
|
61
|
+
"Get a fresh token from ClearSpec Settings and restart the MCP server."
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
cachedUserId = data.user.id;
|
|
66
|
+
return cachedUserId;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function getCachedUserId(): string {
|
|
70
|
+
if (!cachedUserId) {
|
|
71
|
+
throw new Error("Auth not validated yet. Call validateAuth() first.");
|
|
72
|
+
}
|
|
73
|
+
return cachedUserId;
|
|
74
|
+
}
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { createClient, getCachedUserId } from "./supabase.js";
|
|
3
|
+
import { rowToSpec, rowToSpecMeta } from "./types.js";
|
|
4
|
+
import type { Spec, SpecMeta } from "./types.js";
|
|
5
|
+
import { formatSpecAsMarkdown } from "./format.js";
|
|
6
|
+
|
|
7
|
+
const AUTH_ERROR_MSG =
|
|
8
|
+
"Authentication failed. Your token may have expired. " +
|
|
9
|
+
"Get a fresh token from ClearSpec Settings and restart the MCP server.";
|
|
10
|
+
|
|
11
|
+
/** Check if an error is a Supabase auth error (expired/invalid token). */
|
|
12
|
+
function isAuthError(error: unknown): boolean {
|
|
13
|
+
if (error && typeof error === "object" && "code" in error) {
|
|
14
|
+
const code = (error as { code: string }).code;
|
|
15
|
+
return code === "PGRST301" || code === "401" || code === "403";
|
|
16
|
+
}
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// --- Tool input schemas ---
|
|
21
|
+
|
|
22
|
+
export const listSpecsSchema = z.object({
|
|
23
|
+
status: z.string().optional().describe("Filter by status (e.g. 'draft', 'ready_to_build', 'in_progress')"),
|
|
24
|
+
project_id: z.string().optional().describe("Filter by project ID"),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const getSpecSchema = z.object({
|
|
28
|
+
spec_id: z.string().describe("The spec ID to retrieve"),
|
|
29
|
+
include_versions: z.boolean().optional().default(false).describe("Include version history (default: false)"),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const searchSpecsSchema = z.object({
|
|
33
|
+
query: z.string().describe("Search term to match against spec titles"),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const getSpecMarkdownSchema = z.object({
|
|
37
|
+
spec_id: z.string().describe("The spec ID to format as markdown"),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// --- Tool handlers ---
|
|
41
|
+
|
|
42
|
+
export async function listSpecs(input: z.infer<typeof listSpecsSchema>): Promise<SpecMeta[]> {
|
|
43
|
+
const supabase = createClient();
|
|
44
|
+
const userId = getCachedUserId();
|
|
45
|
+
|
|
46
|
+
let query = supabase
|
|
47
|
+
.from("specs")
|
|
48
|
+
.select("id, title, status, completeness_score, project_id, updated_at")
|
|
49
|
+
.eq("user_id", userId)
|
|
50
|
+
.order("updated_at", { ascending: false });
|
|
51
|
+
|
|
52
|
+
if (input.status) {
|
|
53
|
+
query = query.eq("status", input.status);
|
|
54
|
+
}
|
|
55
|
+
if (input.project_id) {
|
|
56
|
+
query = query.eq("project_id", input.project_id);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const { data, error } = await query;
|
|
60
|
+
|
|
61
|
+
if (error) {
|
|
62
|
+
if (isAuthError(error)) throw new Error(AUTH_ERROR_MSG);
|
|
63
|
+
throw new Error(`Failed to list specs: ${error.message}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (data || []).map((row) => rowToSpecMeta(row as Record<string, unknown>));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function getSpec(input: z.infer<typeof getSpecSchema>): Promise<Spec | null> {
|
|
70
|
+
const supabase = createClient();
|
|
71
|
+
const userId = getCachedUserId();
|
|
72
|
+
|
|
73
|
+
const specPromise = supabase
|
|
74
|
+
.from("specs")
|
|
75
|
+
.select("*")
|
|
76
|
+
.eq("id", input.spec_id)
|
|
77
|
+
.eq("user_id", userId)
|
|
78
|
+
.single();
|
|
79
|
+
|
|
80
|
+
if (input.include_versions) {
|
|
81
|
+
const [specResult, versionsResult] = await Promise.all([
|
|
82
|
+
specPromise,
|
|
83
|
+
supabase
|
|
84
|
+
.from("spec_versions")
|
|
85
|
+
.select("*")
|
|
86
|
+
.eq("spec_id", input.spec_id)
|
|
87
|
+
.order("version", { ascending: true }),
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
if (specResult.error) {
|
|
91
|
+
if (isAuthError(specResult.error)) throw new Error(AUTH_ERROR_MSG);
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return rowToSpec(
|
|
96
|
+
specResult.data as Record<string, unknown>,
|
|
97
|
+
(versionsResult.data || []) as Record<string, unknown>[]
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { data, error } = await specPromise;
|
|
102
|
+
|
|
103
|
+
if (error) {
|
|
104
|
+
if (isAuthError(error)) throw new Error(AUTH_ERROR_MSG);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return rowToSpec(data as Record<string, unknown>);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function searchSpecs(input: z.infer<typeof searchSpecsSchema>): Promise<SpecMeta[]> {
|
|
112
|
+
const supabase = createClient();
|
|
113
|
+
const userId = getCachedUserId();
|
|
114
|
+
|
|
115
|
+
const { data, error } = await supabase
|
|
116
|
+
.from("specs")
|
|
117
|
+
.select("id, title, status, completeness_score, project_id, updated_at")
|
|
118
|
+
.eq("user_id", userId)
|
|
119
|
+
.ilike("title", `%${input.query}%`)
|
|
120
|
+
.order("updated_at", { ascending: false });
|
|
121
|
+
|
|
122
|
+
if (error) {
|
|
123
|
+
if (isAuthError(error)) throw new Error(AUTH_ERROR_MSG);
|
|
124
|
+
throw new Error(`Failed to search specs: ${error.message}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (data || []).map((row) => rowToSpecMeta(row as Record<string, unknown>));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function getSpecAsMarkdown(input: z.infer<typeof getSpecMarkdownSchema>): Promise<string> {
|
|
131
|
+
const spec = await getSpec({ spec_id: input.spec_id, include_versions: false });
|
|
132
|
+
|
|
133
|
+
if (!spec) {
|
|
134
|
+
throw new Error(`Spec not found: ${input.spec_id}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return formatSpecAsMarkdown(spec);
|
|
138
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// SYNC: copied from src/types/spec.ts — keep in sync when spec schema changes
|
|
2
|
+
|
|
3
|
+
export interface UserStory {
|
|
4
|
+
id: string;
|
|
5
|
+
persona: string;
|
|
6
|
+
action: string;
|
|
7
|
+
benefit: string;
|
|
8
|
+
acceptanceCriteria: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface EdgeCase {
|
|
12
|
+
id: string;
|
|
13
|
+
description: string;
|
|
14
|
+
resolution: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface FailureState {
|
|
18
|
+
id: string;
|
|
19
|
+
trigger: string;
|
|
20
|
+
behavior: string;
|
|
21
|
+
recovery: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Dependency {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
description: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface VerificationCriterion {
|
|
31
|
+
id: string;
|
|
32
|
+
description: string;
|
|
33
|
+
type: "automated" | "manual";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SpecSection {
|
|
37
|
+
goal: string;
|
|
38
|
+
userStories: UserStory[];
|
|
39
|
+
acceptanceCriteria: string[];
|
|
40
|
+
edgeCases: EdgeCase[];
|
|
41
|
+
failureStates: FailureState[];
|
|
42
|
+
dependencies: Dependency[];
|
|
43
|
+
outOfScope: string[];
|
|
44
|
+
verificationCriteria: VerificationCriterion[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type SpecStatus =
|
|
48
|
+
| "draft"
|
|
49
|
+
| "in_review"
|
|
50
|
+
| "approved"
|
|
51
|
+
| "in_development"
|
|
52
|
+
| "delivered"
|
|
53
|
+
| "in_progress"
|
|
54
|
+
| "ready_to_build"
|
|
55
|
+
| "archived";
|
|
56
|
+
|
|
57
|
+
export interface Spec {
|
|
58
|
+
id: string;
|
|
59
|
+
title: string;
|
|
60
|
+
status: SpecStatus;
|
|
61
|
+
completenessScore: number;
|
|
62
|
+
sections: SpecSection;
|
|
63
|
+
projectId: string | null;
|
|
64
|
+
isSample: boolean;
|
|
65
|
+
shareToken: string | null;
|
|
66
|
+
sharePermission: "view" | "comment" | "edit";
|
|
67
|
+
repoFullName: string | null;
|
|
68
|
+
linearIssueId: string | null;
|
|
69
|
+
linearIssueUrl: string | null;
|
|
70
|
+
createdAt: string;
|
|
71
|
+
updatedAt: string;
|
|
72
|
+
versions: SpecVersion[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface SpecVersion {
|
|
76
|
+
id: string;
|
|
77
|
+
specId: string;
|
|
78
|
+
version: number;
|
|
79
|
+
sections: SpecSection;
|
|
80
|
+
changeNote: string | null;
|
|
81
|
+
changedById: string | null;
|
|
82
|
+
createdAt: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Metadata-only shape for list_specs (no sections/versions). */
|
|
86
|
+
export interface SpecMeta {
|
|
87
|
+
id: string;
|
|
88
|
+
title: string;
|
|
89
|
+
status: SpecStatus;
|
|
90
|
+
completenessScore: number;
|
|
91
|
+
projectId: string | null;
|
|
92
|
+
updatedAt: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// SYNC: copied from src/lib/store/specs.ts rowToSpec()
|
|
96
|
+
export function rowToSpec(row: Record<string, unknown>, versions: Record<string, unknown>[] = []): Spec {
|
|
97
|
+
return {
|
|
98
|
+
id: row.id as string,
|
|
99
|
+
title: row.title as string,
|
|
100
|
+
status: row.status as Spec["status"],
|
|
101
|
+
completenessScore: row.completeness_score as number,
|
|
102
|
+
sections: row.sections as SpecSection,
|
|
103
|
+
projectId: (row.project_id as string) || null,
|
|
104
|
+
isSample: (row.is_sample as boolean) ?? false,
|
|
105
|
+
shareToken: (row.share_token as string) ?? null,
|
|
106
|
+
sharePermission: (row.share_permission as Spec["sharePermission"]) ?? "view",
|
|
107
|
+
repoFullName: (row.repo_full_name as string) ?? null,
|
|
108
|
+
linearIssueId: (row.linear_issue_id as string) ?? null,
|
|
109
|
+
linearIssueUrl: (row.linear_issue_url as string) ?? null,
|
|
110
|
+
createdAt: row.created_at as string,
|
|
111
|
+
updatedAt: row.updated_at as string,
|
|
112
|
+
versions: versions.map((v) => ({
|
|
113
|
+
id: v.id as string,
|
|
114
|
+
specId: v.spec_id as string,
|
|
115
|
+
version: v.version as number,
|
|
116
|
+
sections: v.sections as SpecSection,
|
|
117
|
+
changeNote: (v.change_note as string) ?? null,
|
|
118
|
+
changedById: (v.changed_by_id as string) ?? null,
|
|
119
|
+
createdAt: v.created_at as string,
|
|
120
|
+
})),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function rowToSpecMeta(row: Record<string, unknown>): SpecMeta {
|
|
125
|
+
return {
|
|
126
|
+
id: row.id as string,
|
|
127
|
+
title: row.title as string,
|
|
128
|
+
status: row.status as SpecStatus,
|
|
129
|
+
completenessScore: row.completeness_score as number,
|
|
130
|
+
projectId: (row.project_id as string) || null,
|
|
131
|
+
updatedAt: row.updated_at as string,
|
|
132
|
+
};
|
|
133
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ES2022",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"declaration": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src"]
|
|
14
|
+
}
|