@kynver-app/mcp-agent-os 0.3.65 → 0.3.68

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.
@@ -1,4 +1,4 @@
1
- import { estimateTokensFromChars, RUNTIME_CONTEXT_BUDGET_RECEIPT_V2_SCHEMA_VERSION, type ContextBudgetReceiptSurface, type RuntimeContextBudgetReceiptV2 } from "@kynver-app/runtime";
1
+ import { estimateTokensFromChars, RUNTIME_CONTEXT_BUDGET_RECEIPT_V2_SCHEMA_VERSION, type ContextBudgetReceiptSurface, type RuntimeContextBudgetReceiptV2 } from "@kynver-app/runtime/server/context-budget-receipt";
2
2
  /** MCP tool-context measurement adapter (PR2) — metadata only; does not mutate payloads. */
3
3
  export declare function measureMcpToolContextReceipt(payload: unknown, input?: {
4
4
  agentOsId?: string | null;
@@ -1,4 +1,4 @@
1
- import { buildContextBudgetReceiptV2, estimateTokensFromChars, sectionReceiptFromMarkdown, RUNTIME_CONTEXT_BUDGET_RECEIPT_V2_SCHEMA_VERSION, } from "@kynver-app/runtime";
1
+ import { buildContextBudgetReceiptV2, estimateTokensFromChars, sectionReceiptFromMarkdown, RUNTIME_CONTEXT_BUDGET_RECEIPT_V2_SCHEMA_VERSION, } from "@kynver-app/runtime/server/context-budget-receipt";
2
2
  const MCP_TOOL_RESULT_BUDGET_BYTES = 12_288;
3
3
  function utf8ByteLength(value) {
4
4
  return new TextEncoder().encode(JSON.stringify(value)).length;
@@ -1,4 +1,4 @@
1
- import { type IncomingMessage } from "node:http";
1
+ import { createServer, type IncomingMessage } from "node:http";
2
2
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  export declare const MAX_SSE_BODY_BYTES: number;
4
4
  export type SessionAuth = {
@@ -40,5 +40,5 @@ type RateLimiter = {
40
40
  export declare function createFixedWindowRateLimiter(limit: number, windowMs: number): RateLimiter;
41
41
  export declare function parseJsonBody(req: IncomingMessage, maxBytes?: number): Promise<unknown>;
42
42
  export declare function validateMcporterServerName(name: string): string;
43
- export declare function startKynverMcpSseServer(options: StartKynverMcpSseServerOptions): void;
43
+ export declare function startKynverMcpSseServer(options: StartKynverMcpSseServerOptions): ReturnType<typeof createServer>;
44
44
  export {};
@@ -1,8 +1,9 @@
1
1
  // @generated — synced from packages/kynver-mcp-sse-lib/src/index.ts via scripts/sync-mcp-sse-server.mjs
2
2
  // Do not edit by hand; change the canonical file and re-run the sync script.
3
3
  import { createServer } from "node:http";
4
- import { timingSafeEqual } from "node:crypto";
4
+ import { randomUUID, timingSafeEqual } from "node:crypto";
5
5
  import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
6
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6
7
  export const MAX_SSE_BODY_BYTES = 1 * 1024 * 1024;
7
8
  const FORBIDDEN_QUERY_KEYS = ["apiKey", "secret", "userId"];
8
9
  const TRUTHY_ENV = new Set(["1", "true", "yes", "on"]);
@@ -171,13 +172,15 @@ export function startKynverMcpSseServer(options) {
171
172
  const serviceSecret = process.env.KYNVER_SERVICE_SECRET ?? "";
172
173
  const host = resolveListenHost();
173
174
  const sessions = {};
175
+ const streamableSessions = {};
174
176
  // Resource / abuse limits (env-overridable; in-process per server).
175
177
  const maxSessions = intFromEnv("MCP_SSE_MAX_SESSIONS", 200);
176
178
  const maxSessionsPerUser = intFromEnv("MCP_SSE_MAX_SESSIONS_PER_USER", 20);
177
179
  const idleTimeoutMs = intFromEnv("MCP_SSE_IDLE_TIMEOUT_MS", 5 * 60 * 1000);
178
180
  const rateLimitPerMin = intFromEnv("MCP_SSE_RATE_LIMIT_PER_MIN", 120);
179
181
  const rateLimiter = createFixedWindowRateLimiter(rateLimitPerMin, 60 * 1000);
180
- // Close idle SSE sessions so a flood of half-open connections can't pin memory.
182
+ // Close idle sessions (both transports) so a flood of half-open connections
183
+ // can't pin memory.
181
184
  const idleSweep = setInterval(() => {
182
185
  const now = Date.now();
183
186
  for (const [sid, entry] of Object.entries(sessions)) {
@@ -186,8 +189,17 @@ export function startKynverMcpSseServer(options) {
186
189
  void entry.transport.close().catch(() => { });
187
190
  }
188
191
  }
192
+ for (const [sid, entry] of Object.entries(streamableSessions)) {
193
+ if (now - entry.lastActivity > idleTimeoutMs) {
194
+ delete streamableSessions[sid];
195
+ void entry.transport.close().catch(() => { });
196
+ }
197
+ }
189
198
  }, 60 * 1000);
190
199
  idleSweep.unref?.();
200
+ const activeSessionCount = () => Object.keys(sessions).length + Object.keys(streamableSessions).length;
201
+ const activeSessionCountForUser = (userId) => Object.values(sessions).filter((s) => s.userId === userId).length +
202
+ Object.values(streamableSessions).filter((s) => s.userId === userId).length;
191
203
  const httpServer = createServer(async (req, res) => {
192
204
  const url = parseUrl(req, port);
193
205
  const path = url.pathname;
@@ -224,14 +236,14 @@ export function startKynverMcpSseServer(options) {
224
236
  });
225
237
  return;
226
238
  }
227
- // Connection caps — bound total + per-user concurrent SSE sessions so an
228
- // authenticated caller can't exhaust memory/sockets by opening many.
229
- const allEntries = Object.values(sessions);
230
- if (allEntries.length >= maxSessions) {
239
+ // Connection caps — bound total + per-user concurrent sessions (across
240
+ // both transports) so an authenticated caller can't exhaust
241
+ // memory/sockets by opening many.
242
+ if (activeSessionCount() >= maxSessions) {
231
243
  json(res, 503, { error: "Server at capacity. Try again shortly." });
232
244
  return;
233
245
  }
234
- if (allEntries.filter((s) => s.userId === userId).length >= maxSessionsPerUser) {
246
+ if (activeSessionCountForUser(userId) >= maxSessionsPerUser) {
235
247
  json(res, 429, { error: "Too many concurrent connections for this account." });
236
248
  return;
237
249
  }
@@ -275,6 +287,146 @@ export function startKynverMcpSseServer(options) {
275
287
  }
276
288
  return;
277
289
  }
290
+ // Streamable HTTP transport (MCP spec 2025-03-26+) — the transport modern
291
+ // connector clients (Grok, ChatGPT, Gemini, Claude) expect for remote
292
+ // servers. One path, three verbs: POST carries JSON-RPC (initialize opens a
293
+ // session; the response's mcp-session-id header identifies it), GET opens
294
+ // the standalone server→client SSE stream, DELETE terminates the session.
295
+ // Auth model mirrors the legacy /sse pair: credentials are verified on the
296
+ // initialize POST, bound to the session, and every subsequent request must
297
+ // re-present the same credentials.
298
+ if (path === "/mcp") {
299
+ if (req.method !== "POST" && req.method !== "GET" && req.method !== "DELETE") {
300
+ res.writeHead(405, { "Content-Type": "application/json", Allow: "GET, POST, DELETE" });
301
+ res.end(JSON.stringify({ error: "Method not allowed" }));
302
+ return;
303
+ }
304
+ if (hasForbiddenQueryAuth(url)) {
305
+ json(res, 400, {
306
+ error: "Internal auth via query params is not allowed. Use X-Kynver-User-Id and X-Kynver-Service-Secret headers.",
307
+ });
308
+ return;
309
+ }
310
+ const sessionId = headerValue(req, "mcp-session-id");
311
+ if (sessionId) {
312
+ const entry = streamableSessions[sessionId];
313
+ if (!entry) {
314
+ // 404 (not 400) per spec so a well-behaved client re-initializes.
315
+ json(res, 404, { error: "Unknown or expired MCP session. Re-initialize." });
316
+ return;
317
+ }
318
+ if (!verifyPostAuth(req, url, entry.auth, serviceSecret)) {
319
+ json(res, 401, {
320
+ error: "Unauthorized. Re-send the same credentials used to initialize the MCP session.",
321
+ });
322
+ return;
323
+ }
324
+ if (!rateLimiter.check(`msg:${entry.userId}`)) {
325
+ json(res, 429, { error: "Too many requests. Slow down and retry." });
326
+ return;
327
+ }
328
+ entry.lastActivity = Date.now();
329
+ try {
330
+ const body = req.method === "POST" ? await parseJsonBody(req) : undefined;
331
+ await options.requestContext.run({ userId: entry.userId }, async () => {
332
+ await entry.transport.handleRequest(req, res, body);
333
+ });
334
+ }
335
+ catch (error) {
336
+ if (!res.headersSent) {
337
+ json(res, 400, {
338
+ error: error instanceof Error ? error.message : "Invalid request body",
339
+ });
340
+ }
341
+ }
342
+ return;
343
+ }
344
+ // No session header — only an initialize POST may open a session. (The
345
+ // transport itself rejects a sessionless non-initialize POST with 400.)
346
+ if (req.method !== "POST") {
347
+ json(res, 400, {
348
+ error: "Missing mcp-session-id header. Open a session with an initialize POST first.",
349
+ });
350
+ return;
351
+ }
352
+ // Pre-auth rate limit by client IP (we don't know the user yet).
353
+ if (!rateLimiter.check(`mcp:${clientIp(req)}`)) {
354
+ json(res, 429, { error: "Too many requests. Slow down and retry." });
355
+ return;
356
+ }
357
+ let userId = null;
358
+ let auth = null;
359
+ const internal = serviceSecret ? resolveInternalAuth(req, serviceSecret) : null;
360
+ if (internal) {
361
+ userId = internal.userId;
362
+ auth = internal.auth;
363
+ }
364
+ else {
365
+ const apiKey = bearerToken(req);
366
+ if (apiKey) {
367
+ userId = await resolveUserFromApiKey(baseUrl, apiKey);
368
+ if (userId)
369
+ auth = { kind: "apiKey", apiKey };
370
+ }
371
+ }
372
+ if (!userId || !auth) {
373
+ json(res, 401, {
374
+ error: "Unauthorized. Pass API key via Authorization: Bearer <key> or internal X-Kynver-User-Id + X-Kynver-Service-Secret headers.",
375
+ });
376
+ return;
377
+ }
378
+ if (activeSessionCount() >= maxSessions) {
379
+ json(res, 503, { error: "Server at capacity. Try again shortly." });
380
+ return;
381
+ }
382
+ if (activeSessionCountForUser(userId) >= maxSessionsPerUser) {
383
+ json(res, 429, { error: "Too many concurrent connections for this account." });
384
+ return;
385
+ }
386
+ let body;
387
+ try {
388
+ body = await parseJsonBody(req);
389
+ }
390
+ catch (error) {
391
+ json(res, 400, {
392
+ error: error instanceof Error ? error.message : "Invalid request body",
393
+ });
394
+ return;
395
+ }
396
+ const boundUserId = userId;
397
+ const boundAuth = auth;
398
+ const transport = new StreamableHTTPServerTransport({
399
+ sessionIdGenerator: () => randomUUID(),
400
+ onsessioninitialized: (sid) => {
401
+ streamableSessions[sid] = {
402
+ transport,
403
+ userId: boundUserId,
404
+ auth: boundAuth,
405
+ lastActivity: Date.now(),
406
+ };
407
+ console.log(`[MCP HTTP] New session (${options.label}, user: ${boundUserId.slice(0, 8)}...)`);
408
+ },
409
+ });
410
+ transport.onclose = () => {
411
+ if (transport.sessionId)
412
+ delete streamableSessions[transport.sessionId];
413
+ };
414
+ const server = await options.createMcpServer();
415
+ await server.connect(transport);
416
+ try {
417
+ await options.requestContext.run({ userId: boundUserId }, async () => {
418
+ await transport.handleRequest(req, res, body);
419
+ });
420
+ }
421
+ catch (error) {
422
+ if (!res.headersSent) {
423
+ json(res, 400, {
424
+ error: error instanceof Error ? error.message : "Invalid request",
425
+ });
426
+ }
427
+ }
428
+ return;
429
+ }
278
430
  if (req.method === "GET" && path === "/health") {
279
431
  res.writeHead(200, { "Content-Type": "text/plain" });
280
432
  res.end("ok");
@@ -285,4 +437,5 @@ export function startKynverMcpSseServer(options) {
285
437
  httpServer.listen(port, host, () => {
286
438
  console.log(`[MCP SSE] ${options.label} listening on ${host}:${port}`);
287
439
  });
440
+ return httpServer;
288
441
  }
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { buildChatMemoryShadowPreview, buildLegacyChatMemorySection, } from "@kynver-app/runtime";
2
+ import { buildChatMemoryShadowPreview, buildLegacyChatMemorySection, } from "@kynver-app/runtime/server/context-budget-receipt";
3
3
  function envTruthy(value) {
4
4
  if (!value)
5
5
  return false;
@@ -54,11 +54,23 @@ export function registerWorkbenchTools({ register, resolveToolSlug, jsonResult,
54
54
  view: workbenchView.optional(),
55
55
  target: z.string().optional().describe("EntityRef e.g. task:<id>, plan:<id>"),
56
56
  push: z.boolean().optional().describe("For drill: push onto stack (default true)"),
57
- panel: z.string().optional(),
57
+ panel: z
58
+ .string()
59
+ .optional()
60
+ .describe("For open_panel: plan-detail | task-detail | goal-detail | project-detail | dq | lane-experts | graph-rail | system-console | system-group:<groupId> | system:<panelId>"),
58
61
  tab: z.string().optional(),
59
62
  personaSlug: z.string().optional(),
60
63
  planId: z.string().optional(),
61
64
  prUrl: z.string().optional(),
65
+ refs: z
66
+ .array(z.string())
67
+ .optional()
68
+ .describe("For highlight: EntityRefs to point at (max 20) — renders a visible callout"),
69
+ ttlMs: z
70
+ .number()
71
+ .optional()
72
+ .describe("For highlight: callout duration ms (2000–120000, default 12000)"),
73
+ note: z.string().optional().describe("For annotate: note text (≤500 chars) shown with target"),
62
74
  chip: z.string().optional(),
63
75
  review: z.string().optional(),
64
76
  q: z.string().optional(),
@@ -95,6 +107,12 @@ export function registerWorkbenchTools({ register, resolveToolSlug, jsonResult,
95
107
  case "open_pr_history":
96
108
  body.open_pr_history = { planId: args.planId, prUrl: args.prUrl };
97
109
  break;
110
+ case "highlight":
111
+ body.highlight = { refs: args.refs, ttlMs: args.ttlMs };
112
+ break;
113
+ case "annotate":
114
+ body.annotate = { target: args.target, note: args.note };
115
+ break;
98
116
  case "filter":
99
117
  body.filter = { chip: args.chip, review: args.review, q: args.q };
100
118
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kynver-app/mcp-agent-os",
3
- "version": "0.3.65",
3
+ "version": "0.3.68",
4
4
  "description": "Kynver Agentic OS MCP server — slug-keyed agent identity, goals, projects, sessions, and long-term memory",
5
5
  "type": "module",
6
6
  "bin": {