@koderlabs/tasks-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/dist/index.js ADDED
@@ -0,0 +1,321 @@
1
+ // @koderlabs/tasks-mcp — generated build, do not edit
2
+ import {
3
+ ApiClient,
4
+ ApiError,
5
+ AuthError,
6
+ ScopeSet,
7
+ createMcpServer,
8
+ extractBearerToken,
9
+ replayPersistedAuditQueue,
10
+ validateSecretKey
11
+ } from "./chunk-ULHBL6XY.js";
12
+
13
+ // src/index.ts
14
+ import { serve } from "@hono/node-server";
15
+ import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response";
16
+ import { Hono } from "hono";
17
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
18
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
19
+
20
+ // src/auth/oauth.ts
21
+ function assertIntrospectKeyConfigured(env = process.env) {
22
+ const key = env.MCP_INTROSPECT_KEY?.trim();
23
+ if (!key) {
24
+ throw new AuthError(
25
+ "config_error",
26
+ "MCP_INTROSPECT_KEY env var is required to validate OAuth access tokens"
27
+ );
28
+ }
29
+ if (!key.startsWith("sk_live_") && !key.startsWith("sk_test_")) {
30
+ throw new AuthError(
31
+ "config_error",
32
+ "MCP_INTROSPECT_KEY must be a management key (sk_live_* / sk_test_*)"
33
+ );
34
+ }
35
+ return key;
36
+ }
37
+ function normalizeResource(uri) {
38
+ try {
39
+ const u = new URL(uri);
40
+ u.hash = "";
41
+ u.search = "";
42
+ let s = u.toString();
43
+ if (s.endsWith("/")) s = s.slice(0, -1);
44
+ return s;
45
+ } catch {
46
+ return uri;
47
+ }
48
+ }
49
+ async function validateOAuthToken(token, apiBaseUrl, introspectKey, expectedResource, allowUnboundResource = false) {
50
+ if (!introspectKey) {
51
+ throw new AuthError("config_error", "introspectKey not configured");
52
+ }
53
+ const url = `${apiBaseUrl}/oauth/introspect`;
54
+ let res;
55
+ try {
56
+ res = await fetch(url, {
57
+ method: "POST",
58
+ headers: {
59
+ "Content-Type": "application/json",
60
+ Authorization: `Bearer ${introspectKey}`
61
+ },
62
+ body: JSON.stringify({ token }),
63
+ signal: AbortSignal.timeout(5e3)
64
+ });
65
+ } catch (err) {
66
+ throw new ApiError(`Cannot reach InstantTasks API: ${err.message}`);
67
+ }
68
+ if (!res.ok) {
69
+ throw new AuthError("invalid_token", `introspect returned ${res.status}`);
70
+ }
71
+ let body;
72
+ try {
73
+ body = await res.json();
74
+ } catch (err) {
75
+ throw new ApiError(`Malformed introspect response: ${err.message}`);
76
+ }
77
+ if (!body.active) {
78
+ throw new AuthError("invalid_token", "token is inactive or expired");
79
+ }
80
+ if (!body.userId || !body.organizationId || !Array.isArray(body.scopes) || !body.expiresAt) {
81
+ throw new AuthError("invalid_token", "introspect response missing required fields");
82
+ }
83
+ const resource = (body.resource ?? body.aud ?? []).map(normalizeResource);
84
+ if (expectedResource) {
85
+ const expected = normalizeResource(expectedResource);
86
+ if (resource.length === 0) {
87
+ if (!allowUnboundResource) {
88
+ throw new AuthError("invalid_token", "token has no resource binding (RFC 8707)");
89
+ }
90
+ } else if (!resource.includes(expected)) {
91
+ throw new AuthError("invalid_token", `token audience mismatch \u2014 expected ${expected}, got [${resource.join(", ")}]`);
92
+ }
93
+ }
94
+ return {
95
+ userId: body.userId,
96
+ organizationId: body.organizationId,
97
+ scopes: new ScopeSet(body.scopes),
98
+ kind: "oauth",
99
+ expiresAt: new Date(body.expiresAt),
100
+ resource
101
+ };
102
+ }
103
+
104
+ // src/index.ts
105
+ var PORT = parseInt(process.env.PORT ?? "11005", 10);
106
+ var API_BASE_URL = (process.env.API_BASE_URL ?? "http://api:11002/api/v1").replace(/\/$/, "");
107
+ var MAX_BODY_BYTES = parseInt(process.env.MCP_MAX_BODY_BYTES ?? `${1024 * 1024}`, 10);
108
+ var ALLOWED_ORIGINS = new Set(
109
+ (process.env.MCP_ALLOWED_ORIGINS ?? "").split(",").map((s) => s.trim()).filter(Boolean)
110
+ );
111
+ var RATE_LIMIT_RPM = parseInt(process.env.MCP_RATE_LIMIT_RPM ?? "120", 10);
112
+ var RATE_WINDOW_MS = 6e4;
113
+ var rateBuckets = /* @__PURE__ */ new Map();
114
+ function rateLimitHit(tokenKey) {
115
+ const now = Date.now();
116
+ const bucket = rateBuckets.get(tokenKey);
117
+ if (!bucket || bucket.resetAt <= now) {
118
+ rateBuckets.set(tokenKey, { count: 1, resetAt: now + RATE_WINDOW_MS });
119
+ return { ok: true, retryAfterSec: 0 };
120
+ }
121
+ bucket.count += 1;
122
+ if (bucket.count > RATE_LIMIT_RPM) {
123
+ return { ok: false, retryAfterSec: Math.ceil((bucket.resetAt - now) / 1e3) };
124
+ }
125
+ return { ok: true, retryAfterSec: 0 };
126
+ }
127
+ setInterval(() => {
128
+ const now = Date.now();
129
+ for (const [k, b] of rateBuckets) {
130
+ if (b.resetAt <= now) rateBuckets.delete(k);
131
+ }
132
+ }, RATE_WINDOW_MS).unref();
133
+ async function hashToken(token) {
134
+ const data = new TextEncoder().encode(token);
135
+ const digest = await crypto.subtle.digest("SHA-1", data);
136
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
137
+ }
138
+ var PUBLIC_MCP_URL = (process.env.MCP_PUBLIC_URL ?? "https://tasks.koderlabs.net/mcp").replace(/\/$/, "");
139
+ var PUBLIC_AUTH_ORIGIN = (process.env.MCP_AUTH_ORIGIN ?? "https://tasks.koderlabs.net").replace(/\/$/, "");
140
+ var RESOURCE_METADATA_URL = `${PUBLIC_MCP_URL}/.well-known/oauth-protected-resource`;
141
+ var ALLOW_UNBOUND_TOKENS = process.env.MCP_ALLOW_UNBOUND_TOKENS === "true";
142
+ var MCP_INTROSPECT_KEY = assertIntrospectKeyConfigured(process.env);
143
+ var app = new Hono();
144
+ app.get("/healthz", (c) => {
145
+ return c.json({ status: "ok", service: "instanttasks-mcp", port: PORT });
146
+ });
147
+ function protectedResourceMetadata() {
148
+ return {
149
+ resource: PUBLIC_MCP_URL,
150
+ authorization_servers: [PUBLIC_AUTH_ORIGIN],
151
+ scopes_supported: [
152
+ "projects:read",
153
+ "tickets:read",
154
+ "tickets:write",
155
+ "issues:read",
156
+ "comments:write",
157
+ "releases:read"
158
+ ],
159
+ bearer_methods_supported: ["header"],
160
+ resource_documentation: "https://tasks.koderlabs.net/docs/mcp"
161
+ };
162
+ }
163
+ function authorizationServerMetadata() {
164
+ return {
165
+ issuer: PUBLIC_AUTH_ORIGIN,
166
+ authorization_endpoint: `${PUBLIC_AUTH_ORIGIN}/oauth/authorize`,
167
+ token_endpoint: `${PUBLIC_AUTH_ORIGIN}/api/v1/oauth/token`,
168
+ registration_endpoint: `${PUBLIC_AUTH_ORIGIN}/api/v1/oauth/register`,
169
+ revocation_endpoint: `${PUBLIC_AUTH_ORIGIN}/api/v1/oauth/revoke`,
170
+ introspection_endpoint: `${PUBLIC_AUTH_ORIGIN}/api/v1/oauth/introspect`,
171
+ response_types_supported: ["code"],
172
+ grant_types_supported: ["authorization_code", "refresh_token"],
173
+ code_challenge_methods_supported: ["S256"],
174
+ token_endpoint_auth_methods_supported: ["none", "client_secret_post"],
175
+ // RFC 8707 — advertise resource indicator support so MCP clients know
176
+ // they should bind tokens to the resource server they're authorizing for.
177
+ resource_indicators_supported: true,
178
+ scopes_supported: [
179
+ "projects:read",
180
+ "tickets:read",
181
+ "tickets:write",
182
+ "issues:read",
183
+ "comments:write",
184
+ "releases:read"
185
+ ]
186
+ };
187
+ }
188
+ var protectedRoute = (c) => {
189
+ c.header("Cache-Control", "public, max-age=3600");
190
+ return c.json(protectedResourceMetadata());
191
+ };
192
+ var authServerRoute = (c) => {
193
+ c.header("Cache-Control", "public, max-age=3600");
194
+ return c.json(authorizationServerMetadata());
195
+ };
196
+ app.get("/.well-known/oauth-protected-resource", protectedRoute);
197
+ app.get("/mcp/.well-known/oauth-protected-resource", protectedRoute);
198
+ app.get("/.well-known/oauth-authorization-server", authServerRoute);
199
+ app.get("/mcp/.well-known/oauth-authorization-server", authServerRoute);
200
+ var transports = /* @__PURE__ */ new Map();
201
+ app.all("/mcp", async (c) => {
202
+ const req = c.req.raw;
203
+ const method = req.method;
204
+ const origin = req.headers.get("origin");
205
+ if (origin && !ALLOWED_ORIGINS.has(origin)) {
206
+ return c.json({ error: "origin not allowed" }, 400);
207
+ }
208
+ if (method === "POST") {
209
+ const cl = req.headers.get("content-length");
210
+ if (cl && parseInt(cl, 10) > MAX_BODY_BYTES) {
211
+ return c.json({ error: `payload exceeds ${MAX_BODY_BYTES} bytes` }, 413);
212
+ }
213
+ }
214
+ const authHeader = req.headers.get("authorization") ?? void 0;
215
+ const token = extractBearerToken(authHeader);
216
+ let scopes;
217
+ let apiToken;
218
+ const wwwAuth = `Bearer realm="instanttasks", resource_metadata="${RESOURCE_METADATA_URL}"`;
219
+ if (!token) {
220
+ c.header("WWW-Authenticate", wwwAuth);
221
+ return c.json({ error: "Missing Authorization: Bearer <token> header" }, 401);
222
+ }
223
+ try {
224
+ if (token.startsWith("sk_live_") || token.startsWith("sk_test_")) {
225
+ const identity = await validateSecretKey(token, API_BASE_URL);
226
+ scopes = identity.scopes;
227
+ apiToken = token;
228
+ } else {
229
+ const identity = await validateOAuthToken(
230
+ token,
231
+ API_BASE_URL,
232
+ MCP_INTROSPECT_KEY,
233
+ PUBLIC_MCP_URL,
234
+ ALLOW_UNBOUND_TOKENS
235
+ );
236
+ scopes = identity.scopes;
237
+ apiToken = token;
238
+ }
239
+ } catch (err) {
240
+ const msg = err instanceof Error ? err.message : "Auth failed";
241
+ c.header("WWW-Authenticate", `${wwwAuth}, error="invalid_token", error_description="${msg.replace(/"/g, "'")}"`);
242
+ return c.json({ error: msg }, 401);
243
+ }
244
+ const tokenKey = await hashToken(apiToken);
245
+ const rl = rateLimitHit(tokenKey);
246
+ if (!rl.ok) {
247
+ c.header("Retry-After", String(rl.retryAfterSec));
248
+ return c.json({ error: "rate limit exceeded" }, 429);
249
+ }
250
+ const apiClient = new ApiClient({ baseUrl: API_BASE_URL, token: apiToken });
251
+ const auditClient = new ApiClient({ baseUrl: API_BASE_URL, token: MCP_INTROSPECT_KEY });
252
+ const mcpServer = createMcpServer(() => ({ apiClient, auditClient, scopes }));
253
+ if (method === "POST") {
254
+ const body = await req.json().catch(() => null);
255
+ if (!body) return c.json({ error: "Invalid JSON body" }, 400);
256
+ if (isInitializeRequest(body)) {
257
+ const transport = new StreamableHTTPServerTransport({
258
+ sessionIdGenerator: () => crypto.randomUUID(),
259
+ onsessioninitialized: (sessionId2) => {
260
+ transports.set(sessionId2, transport);
261
+ }
262
+ });
263
+ transport.onclose = () => {
264
+ if (transport.sessionId) transports.delete(transport.sessionId);
265
+ };
266
+ await mcpServer.connect(transport);
267
+ await transport.handleRequest(c.env.incoming, c.env.outgoing, body);
268
+ return RESPONSE_ALREADY_SENT;
269
+ }
270
+ const sessionId = req.headers.get("mcp-session-id");
271
+ if (sessionId && transports.has(sessionId)) {
272
+ const transport = transports.get(sessionId);
273
+ await transport.handleRequest(c.env.incoming, c.env.outgoing, body);
274
+ return RESPONSE_ALREADY_SENT;
275
+ }
276
+ return c.json({ error: "Invalid or missing session" }, 400);
277
+ }
278
+ if (method === "GET") {
279
+ const sessionId = req.headers.get("mcp-session-id") ?? new URL(req.url).searchParams.get("sessionId");
280
+ if (sessionId && transports.has(sessionId)) {
281
+ const transport = transports.get(sessionId);
282
+ await transport.handleRequest(c.env.incoming, c.env.outgoing);
283
+ return RESPONSE_ALREADY_SENT;
284
+ }
285
+ return c.json({ error: "Unknown session for SSE" }, 404);
286
+ }
287
+ if (method === "DELETE") {
288
+ const sessionId = req.headers.get("mcp-session-id");
289
+ if (sessionId && transports.has(sessionId)) {
290
+ const transport = transports.get(sessionId);
291
+ await transport.close();
292
+ transports.delete(sessionId);
293
+ return c.json({ ok: true });
294
+ }
295
+ return c.json({ error: "Session not found" }, 404);
296
+ }
297
+ return c.json({ error: "Method not allowed" }, 405);
298
+ });
299
+ serve({ fetch: app.fetch, port: PORT }, (info) => {
300
+ console.log(`InstantTasks MCP server listening on port ${info.port}`);
301
+ console.log(` MCP endpoint: http://localhost:${info.port}/mcp`);
302
+ console.log(` Health: http://localhost:${info.port}/healthz`);
303
+ console.log(` API base: ${API_BASE_URL}`);
304
+ });
305
+ void (async () => {
306
+ try {
307
+ const replayClient = new ApiClient({ baseUrl: API_BASE_URL, token: MCP_INTROSPECT_KEY });
308
+ const summary = await replayPersistedAuditQueue(
309
+ (body) => (
310
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
311
+ replayClient.post("/audit/mcp", body)
312
+ )
313
+ );
314
+ if (summary.shipped > 0 || summary.remaining > 0) {
315
+ console.log(`[AuditQueue] replay: shipped=${summary.shipped} remaining=${summary.remaining}`);
316
+ }
317
+ } catch (err) {
318
+ console.error("[AuditQueue] replay failed at boot:", err);
319
+ }
320
+ })();
321
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/auth/oauth.ts"],"sourcesContent":["/**\n * HTTP transport entry — Hono app with Streamable HTTP MCP transport.\n *\n * Endpoints:\n * POST /mcp — MCP protocol (Streamable HTTP per spec)\n * GET /mcp — SSE stream for server-initiated messages (optional)\n * GET /healthz — liveness check for Docker / load balancer\n *\n * Auth: Bearer token in Authorization header, either:\n * sk_live_* / sk_test_* → validated via ManagementKeyGuard on the API\n * Other → treated as OAuth access token (introspection)\n *\n * Port: 11005 (reserved in CLAUDE.md port scheme for MCP)\n */\nimport { serve } from '@hono/node-server';\nimport type { HttpBindings } from '@hono/node-server';\n// @hono/node-server's RESPONSE_ALREADY_SENT sentinel tells Hono to skip its\n// own response generation when the underlying ServerResponse has been written\n// to directly (as the MCP transport does for SSE streaming).\nimport { RESPONSE_ALREADY_SENT } from '@hono/node-server/utils/response';\nimport { Hono } from 'hono';\nimport { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';\nimport { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';\nimport { createMcpServer } from './server.js';\nimport { ApiClient, replayPersistedAuditQueue } from './api-client.js';\nimport { ScopeSet } from './auth/scopes.js';\nimport { extractBearerToken, validateSecretKey } from './auth/secret-key.js';\nimport { validateOAuthToken, assertIntrospectKeyConfigured } from './auth/oauth.js';\n\nconst PORT = parseInt(process.env.PORT ?? '11005', 10);\nconst API_BASE_URL = (process.env.API_BASE_URL ?? 'http://api:11002/api/v1').replace(/\\/$/, '');\n\n/**\n * Hard cap on POST /mcp body size (defends against memory-pressure DoS via\n * giant JSON-RPC envelopes). 1 MiB is well above any legitimate tool call.\n * Override with MCP_MAX_BODY_BYTES if a future tool needs more headroom.\n */\nconst MAX_BODY_BYTES = parseInt(process.env.MCP_MAX_BODY_BYTES ?? `${1024 * 1024}`, 10);\n\n/**\n * Comma-separated origin allowlist for browser-based MCP clients.\n *\n * The MCP spec mandates Origin validation on Streamable-HTTP servers to\n * prevent DNS-rebinding attacks (a malicious page on the user's LAN\n * resolves to this server's IP and replays the user's cookie/token).\n *\n * Empty string (default) = allow requests with NO Origin header (typical\n * for native clients: Claude Desktop, mcp-remote, curl). A request that\n * DOES send an Origin must match the allowlist exactly.\n *\n * For browser-hosted MCP clients add e.g.\n * MCP_ALLOWED_ORIGINS=https://tasks.koderlabs.net,https://app.koderlabs.net\n */\nconst ALLOWED_ORIGINS = new Set(\n (process.env.MCP_ALLOWED_ORIGINS ?? '')\n .split(',')\n .map((s) => s.trim())\n .filter(Boolean),\n);\n\n/**\n * Per-token rate limit (POST /mcp). Token bucket keyed by SHA-1 of the\n * Authorization header so rotated tokens get a fresh quota and the\n * raw key never appears in memory traces.\n *\n * Defaults: 120 requests / 60 s. Override via MCP_RATE_LIMIT_RPM.\n */\nconst RATE_LIMIT_RPM = parseInt(process.env.MCP_RATE_LIMIT_RPM ?? '120', 10);\nconst RATE_WINDOW_MS = 60_000;\ntype RateBucket = { count: number; resetAt: number };\nconst rateBuckets = new Map<string, RateBucket>();\n\nfunction rateLimitHit(tokenKey: string): { ok: boolean; retryAfterSec: number } {\n const now = Date.now();\n const bucket = rateBuckets.get(tokenKey);\n if (!bucket || bucket.resetAt <= now) {\n rateBuckets.set(tokenKey, { count: 1, resetAt: now + RATE_WINDOW_MS });\n return { ok: true, retryAfterSec: 0 };\n }\n bucket.count += 1;\n if (bucket.count > RATE_LIMIT_RPM) {\n return { ok: false, retryAfterSec: Math.ceil((bucket.resetAt - now) / 1000) };\n }\n return { ok: true, retryAfterSec: 0 };\n}\n\n/** Periodically GC expired rate buckets so the Map can't grow without bound. */\nsetInterval(() => {\n const now = Date.now();\n for (const [k, b] of rateBuckets) {\n if (b.resetAt <= now) rateBuckets.delete(k);\n }\n}, RATE_WINDOW_MS).unref();\n\n/** Stable key for rate-limit bucketing — SHA-1 over the Bearer token. */\nasync function hashToken(token: string): Promise<string> {\n const data = new TextEncoder().encode(token);\n const digest = await crypto.subtle.digest('SHA-1', data);\n return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');\n}\n\n/** Public-facing URL of this MCP server. Used in discovery metadata so\n * clients building authorization URLs know where to register and where\n * to introspect tokens. Defaults to the path-based deploy on the main\n * domain; flip to mcp.tasks.koderlabs.net once DNS is provisioned. */\nconst PUBLIC_MCP_URL = (process.env.MCP_PUBLIC_URL ?? 'https://tasks.koderlabs.net/mcp').replace(/\\/$/, '');\n/** Authorization-server origin — usually the API itself. */\nconst PUBLIC_AUTH_ORIGIN = (process.env.MCP_AUTH_ORIGIN ?? 'https://tasks.koderlabs.net').replace(/\\/$/, '');\n/** Discovery URL the WWW-Authenticate header points at. */\nconst RESOURCE_METADATA_URL = `${PUBLIC_MCP_URL}/.well-known/oauth-protected-resource`;\n\n/**\n * Migration toggle for RFC 8707 enforcement. Set to `true` while clients\n * still issue tokens without the `resource` param. Default `false` — refuse\n * unbound tokens once all clients are upgraded.\n */\nconst ALLOW_UNBOUND_TOKENS = process.env.MCP_ALLOW_UNBOUND_TOKENS === 'true';\n\n// Fail-fast at startup: OAuth introspection requires a management key.\n// Throwing here means the process refuses to boot in a misconfigured state.\nconst MCP_INTROSPECT_KEY = assertIntrospectKeyConfigured(process.env);\n\nconst app = new Hono<{ Bindings: HttpBindings }>();\n\n// ── Healthcheck ───────────────────────────────────────────────────────────────\n\napp.get('/healthz', (c) => {\n return c.json({ status: 'ok', service: 'instanttasks-mcp', port: PORT });\n});\n\n// ── OAuth Discovery (RFC 9728 + RFC 8414) ────────────────────────────────────\n// Lets MCP-spec clients (mcp-remote, Claude Desktop, Claude Code) discover\n// the auth server without out-of-band config. Both files are unauthenticated,\n// cached for 1h.\n\nfunction protectedResourceMetadata() {\n return {\n resource: PUBLIC_MCP_URL,\n authorization_servers: [PUBLIC_AUTH_ORIGIN],\n scopes_supported: [\n 'projects:read',\n 'tickets:read',\n 'tickets:write',\n 'issues:read',\n 'comments:write',\n 'releases:read',\n ],\n bearer_methods_supported: ['header'],\n resource_documentation: 'https://tasks.koderlabs.net/docs/mcp',\n };\n}\n\nfunction authorizationServerMetadata() {\n return {\n issuer: PUBLIC_AUTH_ORIGIN,\n authorization_endpoint: `${PUBLIC_AUTH_ORIGIN}/oauth/authorize`,\n token_endpoint: `${PUBLIC_AUTH_ORIGIN}/api/v1/oauth/token`,\n registration_endpoint: `${PUBLIC_AUTH_ORIGIN}/api/v1/oauth/register`,\n revocation_endpoint: `${PUBLIC_AUTH_ORIGIN}/api/v1/oauth/revoke`,\n introspection_endpoint: `${PUBLIC_AUTH_ORIGIN}/api/v1/oauth/introspect`,\n response_types_supported: ['code'],\n grant_types_supported: ['authorization_code', 'refresh_token'],\n code_challenge_methods_supported: ['S256'],\n token_endpoint_auth_methods_supported: ['none', 'client_secret_post'],\n // RFC 8707 — advertise resource indicator support so MCP clients know\n // they should bind tokens to the resource server they're authorizing for.\n resource_indicators_supported: true,\n scopes_supported: [\n 'projects:read',\n 'tickets:read',\n 'tickets:write',\n 'issues:read',\n 'comments:write',\n 'releases:read',\n ],\n };\n}\n\n// Serve discovery at BOTH path roots — subdomain deploy hits\n// `/.well-known/...` directly, path-based deploy keeps the /mcp prefix\n// because Caddy forwards the full URL.\nconst protectedRoute = (c: { header: (k: string, v: string) => void; json: (b: unknown) => Response }) => {\n c.header('Cache-Control', 'public, max-age=3600');\n return c.json(protectedResourceMetadata());\n};\nconst authServerRoute = (c: { header: (k: string, v: string) => void; json: (b: unknown) => Response }) => {\n c.header('Cache-Control', 'public, max-age=3600');\n return c.json(authorizationServerMetadata());\n};\n\napp.get('/.well-known/oauth-protected-resource', protectedRoute as never);\napp.get('/mcp/.well-known/oauth-protected-resource', protectedRoute as never);\napp.get('/.well-known/oauth-authorization-server', authServerRoute as never);\napp.get('/mcp/.well-known/oauth-authorization-server', authServerRoute as never);\n\n// ── MCP Streamable HTTP transport ─────────────────────────────────────────────\n\n// Session map: sessionId → transport (for stateful SSE sessions)\nconst transports = new Map<string, StreamableHTTPServerTransport>();\n\napp.all('/mcp', async (c) => {\n const req = c.req.raw;\n const method = req.method;\n\n // ── Origin allowlist (DNS-rebinding defence) ───────────────────────────\n // Native clients (Claude Desktop, mcp-remote, curl) do NOT send Origin,\n // so a missing header is allowed. Browser clients MUST match the\n // allowlist exactly. Empty allowlist (default) = lock browsers out\n // entirely; operator opts in via MCP_ALLOWED_ORIGINS.\n const origin = req.headers.get('origin');\n if (origin && !ALLOWED_ORIGINS.has(origin)) {\n // 400 (not 403): Claude clients treat 401/403 on POST /mcp as a re-auth\n // signal and kick off the OAuth flow. Origin failure is a configuration\n // problem, not an auth problem — return 400 so the client surfaces the\n // real error instead of looping through OAuth.\n return c.json({ error: 'origin not allowed' }, 400);\n }\n\n // ── Body size guard (memory DoS defence) ───────────────────────────────\n // Trust Content-Length when present (cheapest reject); a missing or\n // 0-length header on POST is allowed through and falls back to the\n // transport's own JSON parser, which itself errors on malformed bodies.\n if (method === 'POST') {\n const cl = req.headers.get('content-length');\n if (cl && parseInt(cl, 10) > MAX_BODY_BYTES) {\n return c.json({ error: `payload exceeds ${MAX_BODY_BYTES} bytes` }, 413);\n }\n }\n\n // Derive token + build ApiClient for this request\n const authHeader = req.headers.get('authorization') ?? undefined;\n const token = extractBearerToken(authHeader);\n\n let scopes: ScopeSet;\n let apiToken: string;\n\n // Per MCP spec: 401 MUST include WWW-Authenticate so the client knows\n // where to discover the auth server. mcp-remote / Claude Desktop kick\n // off OAuth discovery on this header.\n const wwwAuth = `Bearer realm=\"instanttasks\", resource_metadata=\"${RESOURCE_METADATA_URL}\"`;\n\n if (!token) {\n c.header('WWW-Authenticate', wwwAuth);\n return c.json({ error: 'Missing Authorization: Bearer <token> header' }, 401);\n }\n\n try {\n if (token.startsWith('sk_live_') || token.startsWith('sk_test_')) {\n const identity = await validateSecretKey(token, API_BASE_URL);\n scopes = identity.scopes;\n apiToken = token;\n } else {\n const identity = await validateOAuthToken(\n token,\n API_BASE_URL,\n MCP_INTROSPECT_KEY,\n PUBLIC_MCP_URL,\n ALLOW_UNBOUND_TOKENS,\n );\n scopes = identity.scopes;\n apiToken = token;\n }\n } catch (err) {\n const msg = err instanceof Error ? err.message : 'Auth failed';\n c.header('WWW-Authenticate', `${wwwAuth}, error=\"invalid_token\", error_description=\"${msg.replace(/\"/g, \"'\")}\"`);\n return c.json({ error: msg }, 401);\n }\n\n // ── Per-token rate limit ──────────────────────────────────────────────\n // Bucketed by SHA-1 of the Bearer token so the raw key never appears in\n // memory traces and rotating a key resets the quota immediately.\n const tokenKey = await hashToken(apiToken);\n const rl = rateLimitHit(tokenKey);\n if (!rl.ok) {\n c.header('Retry-After', String(rl.retryAfterSec));\n return c.json({ error: 'rate limit exceeded' }, 429);\n }\n\n const apiClient = new ApiClient({ baseUrl: API_BASE_URL, token: apiToken });\n // Dedicated audit client — POST /audit/mcp is gated by ManagementKeyGuard\n // so a user-scoped OAuth token (apiToken) would 403. Reuse the introspect\n // management key for this purpose.\n const auditClient = new ApiClient({ baseUrl: API_BASE_URL, token: MCP_INTROSPECT_KEY });\n const mcpServer = createMcpServer(() => ({ apiClient, auditClient, scopes }));\n\n if (method === 'POST') {\n const body = await req.json().catch(() => null);\n if (!body) return c.json({ error: 'Invalid JSON body' }, 400);\n\n if (isInitializeRequest(body)) {\n // Stateless mode: new transport per initialize\n const transport = new StreamableHTTPServerTransport({\n sessionIdGenerator: () => crypto.randomUUID(),\n onsessioninitialized: (sessionId) => {\n transports.set(sessionId, transport);\n },\n });\n\n transport.onclose = () => {\n if (transport.sessionId) transports.delete(transport.sessionId);\n };\n\n await mcpServer.connect(transport);\n await transport.handleRequest(c.env.incoming, c.env.outgoing, body);\n return RESPONSE_ALREADY_SENT;\n }\n\n // Existing session\n const sessionId = req.headers.get('mcp-session-id');\n if (sessionId && transports.has(sessionId)) {\n const transport = transports.get(sessionId)!;\n await transport.handleRequest(c.env.incoming, c.env.outgoing, body);\n return RESPONSE_ALREADY_SENT;\n }\n\n return c.json({ error: 'Invalid or missing session' }, 400);\n }\n\n if (method === 'GET') {\n // SSE stream for server-initiated messages\n const sessionId = req.headers.get('mcp-session-id') ?? new URL(req.url).searchParams.get('sessionId');\n if (sessionId && transports.has(sessionId)) {\n const transport = transports.get(sessionId)!;\n await transport.handleRequest(c.env.incoming, c.env.outgoing);\n return RESPONSE_ALREADY_SENT;\n }\n return c.json({ error: 'Unknown session for SSE' }, 404);\n }\n\n if (method === 'DELETE') {\n const sessionId = req.headers.get('mcp-session-id');\n if (sessionId && transports.has(sessionId)) {\n const transport = transports.get(sessionId)!;\n await transport.close();\n transports.delete(sessionId);\n return c.json({ ok: true });\n }\n return c.json({ error: 'Session not found' }, 404);\n }\n\n return c.json({ error: 'Method not allowed' }, 405);\n});\n\n// ── Start server ──────────────────────────────────────────────────────────────\n\nserve({ fetch: app.fetch, port: PORT }, (info) => {\n console.log(`InstantTasks MCP server listening on port ${info.port}`);\n console.log(` MCP endpoint: http://localhost:${info.port}/mcp`);\n console.log(` Health: http://localhost:${info.port}/healthz`);\n console.log(` API base: ${API_BASE_URL}`);\n});\n\n// Replay any audit records that failed to ship before the previous shutdown.\n// Uses MCP_INTROSPECT_KEY (the only management key available to the server)\n// so the replay is independent of per-request user tokens. Fire-and-forget —\n// failures back-pressure into the file and retry on the next boot.\nvoid (async () => {\n try {\n const replayClient = new ApiClient({ baseUrl: API_BASE_URL, token: MCP_INTROSPECT_KEY });\n const summary = await replayPersistedAuditQueue((body) =>\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n (replayClient as any).post('/audit/mcp', body),\n );\n if (summary.shipped > 0 || summary.remaining > 0) {\n console.log(`[AuditQueue] replay: shipped=${summary.shipped} remaining=${summary.remaining}`);\n }\n } catch (err) {\n console.error('[AuditQueue] replay failed at boot:', err);\n }\n})();\n","/**\n * OAuth bearer token validator.\n *\n * Real OAuth flow lives in apps/api/src/modules/oauth-apps/. This module\n * validates user-issued access tokens by calling `POST /oauth/introspect`\n * on the API.\n *\n * Because `/oauth/introspect` is gated by ManagementKeyGuard, the MCP server\n * presents an internal management key (env `MCP_INTROSPECT_KEY`) on every\n * introspection call and passes the user's access token in the request body\n * (RFC 7662). The server fails fast at startup if `MCP_INTROSPECT_KEY` is\n * missing — see `assertIntrospectKeyConfigured`.\n *\n * Any non-200 response (including 404) is treated as `invalid_token`;\n * there is no permissive fallback.\n */\nimport { ApiError, AuthError } from '../errors.js';\nimport { ScopeSet } from './scopes.js';\n\nexport interface OAuthIdentity {\n userId: string;\n organizationId: string;\n scopes: ScopeSet;\n kind: 'oauth';\n expiresAt: Date;\n /**\n * RFC 8707 audience set echoed from /oauth/introspect. Empty array =\n * legacy token issued before resource binding was supported (still\n * accepted, but logged so we can track migration).\n */\n resource: string[];\n}\n\n/**\n * Read and return the configured introspect management key, throwing a fatal\n * `AuthError` if missing. Designed to be called from the HTTP/stdio entry\n * points at startup so the process refuses to boot in a misconfigured state.\n */\nexport function assertIntrospectKeyConfigured(env: NodeJS.ProcessEnv = process.env): string {\n const key = env.MCP_INTROSPECT_KEY?.trim();\n if (!key) {\n throw new AuthError(\n 'config_error',\n 'MCP_INTROSPECT_KEY env var is required to validate OAuth access tokens',\n );\n }\n if (!key.startsWith('sk_live_') && !key.startsWith('sk_test_')) {\n throw new AuthError(\n 'config_error',\n 'MCP_INTROSPECT_KEY must be a management key (sk_live_* / sk_test_*)',\n );\n }\n return key;\n}\n\n/**\n * Validate an OAuth access token against the InstantTasks API's\n * token introspection endpoint.\n *\n * @param token - Raw user access token from Authorization: Bearer header\n * @param apiBaseUrl - InstantTasks API base URL\n * @param introspectKey - Management key (sk_live_* / sk_test_*) used to\n * authenticate the introspection call itself. Read\n * from env MCP_INTROSPECT_KEY by the caller.\n */\n/** Normalize a URL the same way the auth server does (RFC 8707). */\nfunction normalizeResource(uri: string): string {\n try {\n const u = new URL(uri);\n u.hash = '';\n u.search = '';\n let s = u.toString();\n if (s.endsWith('/')) s = s.slice(0, -1);\n return s;\n } catch {\n return uri;\n }\n}\n\nexport async function validateOAuthToken(\n token: string,\n apiBaseUrl: string,\n introspectKey: string,\n /**\n * This MCP server's canonical public URL. If provided, the token's\n * `resource` claim must include it. Empty `resource` (legacy tokens)\n * is permitted ONLY when `allowUnboundResource` is true.\n */\n expectedResource?: string,\n allowUnboundResource = false,\n): Promise<OAuthIdentity> {\n if (!introspectKey) {\n throw new AuthError('config_error', 'introspectKey not configured');\n }\n\n const url = `${apiBaseUrl}/oauth/introspect`;\n\n let res: Response;\n try {\n res = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${introspectKey}`,\n },\n body: JSON.stringify({ token }),\n signal: AbortSignal.timeout(5000),\n });\n } catch (err) {\n throw new ApiError(`Cannot reach InstantTasks API: ${(err as Error).message}`);\n }\n\n if (!res.ok) {\n // Any non-200 — including 404 (endpoint missing), 401, 403 — is fatal.\n // We never fall back to a stub identity.\n throw new AuthError('invalid_token', `introspect returned ${res.status}`);\n }\n\n let body: {\n active: boolean;\n userId?: string;\n organizationId?: string;\n scopes?: string[];\n expiresAt?: string;\n resource?: string[];\n aud?: string[];\n };\n try {\n body = (await res.json()) as typeof body;\n } catch (err) {\n throw new ApiError(`Malformed introspect response: ${(err as Error).message}`);\n }\n\n if (!body.active) {\n throw new AuthError('invalid_token', 'token is inactive or expired');\n }\n\n if (!body.userId || !body.organizationId || !Array.isArray(body.scopes) || !body.expiresAt) {\n throw new AuthError('invalid_token', 'introspect response missing required fields');\n }\n\n const resource = (body.resource ?? body.aud ?? []).map(normalizeResource);\n\n // RFC 8707 audience binding check. Refuse tokens issued for a different\n // MCP server even if the auth server owns both — this is the whole point\n // of the resource indicator.\n if (expectedResource) {\n const expected = normalizeResource(expectedResource);\n if (resource.length === 0) {\n if (!allowUnboundResource) {\n throw new AuthError('invalid_token', 'token has no resource binding (RFC 8707)');\n }\n } else if (!resource.includes(expected)) {\n throw new AuthError('invalid_token', `token audience mismatch — expected ${expected}, got [${resource.join(', ')}]`);\n }\n }\n\n return {\n userId: body.userId,\n organizationId: body.organizationId,\n scopes: new ScopeSet(body.scopes),\n kind: 'oauth',\n expiresAt: new Date(body.expiresAt),\n resource,\n };\n}\n"],"mappings":"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","names":["sessionId"]}
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/stdio.js ADDED
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ // @koderlabs/tasks-mcp — generated build, do not edit
3
+ import {
4
+ ApiClient,
5
+ createMcpServer,
6
+ replayPersistedAuditQueue,
7
+ validateSecretKey
8
+ } from "./chunk-ULHBL6XY.js";
9
+
10
+ // src/stdio.ts
11
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
+ var API_BASE_URL = (process.env.IT_API_BASE_URL ?? "http://localhost:11002/api/v1").replace(/\/$/, "");
13
+ var SECRET_KEY = process.env.IT_SECRET_KEY ?? "";
14
+ async function main() {
15
+ if (!SECRET_KEY) {
16
+ process.stderr.write(
17
+ "Error: IT_SECRET_KEY environment variable is required.\nSet it to a management key (sk_live_*) from your InstantTasks project.\n"
18
+ );
19
+ process.exit(1);
20
+ }
21
+ let scopes;
22
+ try {
23
+ const identity = await validateSecretKey(SECRET_KEY, API_BASE_URL);
24
+ scopes = identity.scopes;
25
+ process.stderr.write(`InstantTasks MCP stdio: authenticated (scopes: ${scopes.toArray().join(", ")})
26
+ `);
27
+ } catch (err) {
28
+ process.stderr.write(`InstantTasks MCP stdio: auth failed \u2014 ${err.message}
29
+ `);
30
+ process.exit(2);
31
+ throw err;
32
+ }
33
+ const apiClient = new ApiClient({ baseUrl: API_BASE_URL, token: SECRET_KEY });
34
+ void replayPersistedAuditQueue(
35
+ (body) => (
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ apiClient.post("/audit/mcp", body)
38
+ )
39
+ ).then((summary) => {
40
+ if (summary.shipped > 0 || summary.remaining > 0) {
41
+ process.stderr.write(
42
+ `[AuditQueue] replay: shipped=${summary.shipped} remaining=${summary.remaining}
43
+ `
44
+ );
45
+ }
46
+ }).catch((err) => process.stderr.write(`[AuditQueue] replay failed: ${err.message}
47
+ `));
48
+ const server = createMcpServer(() => ({ apiClient, auditClient: apiClient, scopes }));
49
+ const transport = new StdioServerTransport();
50
+ await server.connect(transport);
51
+ process.stderr.write("InstantTasks MCP server ready on stdio.\n");
52
+ }
53
+ main().catch((err) => {
54
+ process.stderr.write(`Fatal: ${err.message}
55
+ `);
56
+ process.exit(1);
57
+ });
58
+ //# sourceMappingURL=stdio.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/stdio.ts"],"sourcesContent":["#!/usr/bin/env node\n/**\n * stdio transport entry — for `npx @koderlabs/tasks-mcp-stdio`.\n *\n * Reads API credentials from environment variables:\n * IT_API_BASE_URL — InstantTasks API base URL (default: http://localhost:11002/api/v1)\n * IT_SECRET_KEY — Management key (sk_live_* or sk_test_*)\n *\n * Usage (Claude Code):\n * claude mcp add InstantTasks --command \"npx @koderlabs/tasks-mcp-stdio\"\n *\n * Usage (Cursor / Windsurf):\n * See docs/setup/mcp-install.md\n */\nimport { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';\nimport { createMcpServer } from './server.js';\nimport { ApiClient, replayPersistedAuditQueue } from './api-client.js';\nimport { ScopeSet } from './auth/scopes.js';\nimport { validateSecretKey } from './auth/secret-key.js';\n\nconst API_BASE_URL = (process.env.IT_API_BASE_URL ?? 'http://localhost:11002/api/v1').replace(/\\/$/, '');\nconst SECRET_KEY = process.env.IT_SECRET_KEY ?? '';\n\nasync function main(): Promise<void> {\n if (!SECRET_KEY) {\n process.stderr.write(\n 'Error: IT_SECRET_KEY environment variable is required.\\n' +\n 'Set it to a management key (sk_live_*) from your InstantTasks project.\\n',\n );\n process.exit(1);\n }\n\n // Validate key once at startup. On failure, refuse to start — never fall\n // back to permissive scopes.\n let scopes: ScopeSet;\n try {\n const identity = await validateSecretKey(SECRET_KEY, API_BASE_URL);\n scopes = identity.scopes;\n process.stderr.write(`InstantTasks MCP stdio: authenticated (scopes: ${scopes.toArray().join(', ')})\\n`);\n } catch (err) {\n process.stderr.write(`InstantTasks MCP stdio: auth failed — ${(err as Error).message}\\n`);\n process.exit(2);\n throw err; // unreachable — narrows scopes for TS\n }\n\n const apiClient = new ApiClient({ baseUrl: API_BASE_URL, token: SECRET_KEY });\n\n // Replay persisted audit records from a previous run before serving new\n // requests. Fire-and-forget — back-pressure stays in the file across\n // restarts so we never block stdio startup on a flaky API.\n void replayPersistedAuditQueue((body) =>\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n (apiClient as any).post('/audit/mcp', body),\n )\n .then((summary) => {\n if (summary.shipped > 0 || summary.remaining > 0) {\n process.stderr.write(\n `[AuditQueue] replay: shipped=${summary.shipped} remaining=${summary.remaining}\\n`,\n );\n }\n })\n .catch((err) => process.stderr.write(`[AuditQueue] replay failed: ${(err as Error).message}\\n`));\n\n // In stdio the caller IS a management key, so the same client can ship\n // audit records. Aliasing keeps the ServerContext shape consistent across\n // transports.\n const server = createMcpServer(() => ({ apiClient, auditClient: apiClient, scopes }));\n\n const transport = new StdioServerTransport();\n await server.connect(transport);\n\n process.stderr.write('InstantTasks MCP server ready on stdio.\\n');\n}\n\nmain().catch((err) => {\n process.stderr.write(`Fatal: ${err.message}\\n`);\n process.exit(1);\n});\n"],"mappings":";;;;;;;;;;AAcA,SAAS,4BAA4B;AAMrC,IAAM,gBAAgB,QAAQ,IAAI,mBAAmB,iCAAiC,QAAQ,OAAO,EAAE;AACvG,IAAM,aAAa,QAAQ,IAAI,iBAAiB;AAEhD,eAAe,OAAsB;AACnC,MAAI,CAAC,YAAY;AACf,YAAQ,OAAO;AAAA,MACb;AAAA,IAEF;AACA,YAAQ,KAAK,CAAC;AAAA,EAChB;AAIA,MAAI;AACJ,MAAI;AACF,UAAM,WAAW,MAAM,kBAAkB,YAAY,YAAY;AACjE,aAAS,SAAS;AAClB,YAAQ,OAAO,MAAM,kDAAkD,OAAO,QAAQ,EAAE,KAAK,IAAI,CAAC;AAAA,CAAK;AAAA,EACzG,SAAS,KAAK;AACZ,YAAQ,OAAO,MAAM,8CAA0C,IAAc,OAAO;AAAA,CAAI;AACxF,YAAQ,KAAK,CAAC;AACd,UAAM;AAAA,EACR;AAEA,QAAM,YAAY,IAAI,UAAU,EAAE,SAAS,cAAc,OAAO,WAAW,CAAC;AAK5E,OAAK;AAAA,IAA0B,CAAC;AAAA;AAAA,MAE7B,UAAkB,KAAK,cAAc,IAAI;AAAA;AAAA,EAC5C,EACG,KAAK,CAAC,YAAY;AACjB,QAAI,QAAQ,UAAU,KAAK,QAAQ,YAAY,GAAG;AAChD,cAAQ,OAAO;AAAA,QACb,gCAAgC,QAAQ,OAAO,cAAc,QAAQ,SAAS;AAAA;AAAA,MAChF;AAAA,IACF;AAAA,EACF,CAAC,EACA,MAAM,CAAC,QAAQ,QAAQ,OAAO,MAAM,+BAAgC,IAAc,OAAO;AAAA,CAAI,CAAC;AAKjG,QAAM,SAAS,gBAAgB,OAAO,EAAE,WAAW,aAAa,WAAW,OAAO,EAAE;AAEpF,QAAM,YAAY,IAAI,qBAAqB;AAC3C,QAAM,OAAO,QAAQ,SAAS;AAE9B,UAAQ,OAAO,MAAM,2CAA2C;AAClE;AAEA,KAAK,EAAE,MAAM,CAAC,QAAQ;AACpB,UAAQ,OAAO,MAAM,UAAU,IAAI,OAAO;AAAA,CAAI;AAC9C,UAAQ,KAAK,CAAC;AAChB,CAAC;","names":[]}
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@koderlabs/tasks-mcp",
3
+ "version": "0.1.0",
4
+ "description": "InstantTasks MCP server — Streamable HTTP + stdio transports",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "bin": {
8
+ "tasks-mcp-stdio": "./dist/stdio.js"
9
+ },
10
+ "dependencies": {
11
+ "@hono/node-server": "^1.12.0",
12
+ "@koderlabs/tasks-sdk-types": "^0.1.1",
13
+ "@modelcontextprotocol/sdk": "^1.10.0",
14
+ "hono": "^4.6.0",
15
+ "zod": "^3.23.0",
16
+ "zod-to-json-schema": "^3.25.2"
17
+ },
18
+ "devDependencies": {
19
+ "@types/jest": "^29.0.0",
20
+ "@types/node": "^22.0.0",
21
+ "jest": "^29.0.0",
22
+ "ts-jest": "^29.0.0",
23
+ "tsup": "^8.3.0",
24
+ "typescript": "^5.5.0",
25
+ "vitest": "^2.1.0"
26
+ },
27
+ "license": "UNLICENSED",
28
+ "author": {
29
+ "name": "Jawaid Gadiwala",
30
+ "email": "jawaidgadiwala@gmail.com",
31
+ "url": "https://koderlabs.com"
32
+ },
33
+ "homepage": "https://github.com/jawaidgadiwala/instant-tasks/tree/main/apps/mcp#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/jawaidgadiwala/instant-tasks/issues"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/jawaidgadiwala/instant-tasks.git",
40
+ "directory": "apps/mcp"
41
+ },
42
+ "engines": {
43
+ "node": ">=20"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "keywords": [
49
+ "instanttasks",
50
+ "mcp",
51
+ "model-context-protocol",
52
+ "tasks",
53
+ "project-management"
54
+ ],
55
+ "files": [
56
+ "dist",
57
+ "LICENSE"
58
+ ],
59
+ "sideEffects": false,
60
+ "exports": {
61
+ ".": {
62
+ "types": "./dist/index.d.ts",
63
+ "import": "./dist/index.js",
64
+ "require": "./dist/index.cjs"
65
+ }
66
+ },
67
+ "module": "./dist/index.js",
68
+ "types": "./dist/index.d.ts",
69
+ "scripts": {
70
+ "dev": "tsup --watch",
71
+ "build": "tsup",
72
+ "start": "node dist/index.js",
73
+ "start:stdio": "node dist/stdio.js",
74
+ "typecheck": "tsc --noEmit",
75
+ "test": "vitest run"
76
+ }
77
+ }