shraga 0.0.3 → 0.1.2
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/README.md +82 -27
- package/defaults/agents/summarizer.md +16 -0
- package/defaults/agents/trace-extractor.md +84 -0
- package/defaults/bin/claude +45 -0
- package/defaults/bin/claude-revive +17 -0
- package/defaults/extensions/README.md +70 -0
- package/defaults/extensions/selftest.ext.ts +43 -0
- package/defaults/extensions/stripe-webhook.ext.ts +58 -0
- package/defaults/gmail-triage-prompt.md +42 -0
- package/defaults/scripts/README +4 -0
- package/defaults/scripts/agent-once.ts +67 -0
- package/defaults/scripts/backfill-slack-usernames.ts +82 -0
- package/defaults/scripts/notifier-throttle.ts +44 -0
- package/defaults/scripts/summarize-conversations.ts +5 -0
- package/defaults/shraga.config.ts +29 -0
- package/defaults/skills/add-skill.md +14 -0
- package/defaults/skills/artifacts.md +116 -0
- package/defaults/skills/code-review.md +26 -0
- package/defaults/skills/communications.md +54 -0
- package/defaults/skills/context-audit.md +87 -0
- package/defaults/skills/debug.md +10 -0
- package/defaults/skills/garden.md +179 -0
- package/defaults/skills/github-contributor.md +35 -0
- package/defaults/skills/identity.md +30 -0
- package/defaults/skills/mcp-server.md +62 -0
- package/defaults/skills/mcps-sync.md +105 -0
- package/defaults/skills/plan.md +9 -0
- package/defaults/skills/platform.md +177 -0
- package/defaults/skills/reconcile.md +239 -0
- package/defaults/skills/scheduler.md +192 -0
- package/defaults/skills/self-aware.md +136 -0
- package/defaults/skills/shraga-know.md +333 -0
- package/defaults/skills/stripe.md +55 -0
- package/defaults/skills/write-tests.md +10 -0
- package/defaults/skills-defaults.json +1 -0
- package/defaults/system-prompt.md +46 -0
- package/defaults/workspace/context.md +28 -0
- package/defaults/workspace.md +50 -0
- package/defaults/zdotdir/.gitignore +8 -0
- package/defaults/zdotdir/.zlogin +3 -0
- package/defaults/zdotdir/.zprofile +1 -0
- package/defaults/zdotdir/.zshenv +4 -0
- package/defaults/zdotdir/.zshrc +3 -0
- package/dist/client/assets/index-BoHttkMt.js +1940 -0
- package/dist/client/assets/index-DdibEb2O.css +10 -0
- package/dist/client/index.html +22 -0
- package/package.json +59 -14
- package/src/cli.ts +71 -46
- package/src/client/App.tsx +510 -0
- package/src/client/components/ArtifactCard.tsx +26 -0
- package/src/client/components/ArtifactPanel.tsx +138 -0
- package/src/client/components/AuthedImage.tsx +85 -0
- package/src/client/components/AutocompleteTextarea.tsx +149 -0
- package/src/client/components/ChatView.tsx +866 -0
- package/src/client/components/CliAuthConsent.tsx +98 -0
- package/src/client/components/ConfigPanel.tsx +328 -0
- package/src/client/components/ConversationHeader.tsx +156 -0
- package/src/client/components/ConversationPane.tsx +277 -0
- package/src/client/components/LoginPage.tsx +81 -0
- package/src/client/components/MachineStats.tsx +77 -0
- package/src/client/components/McpManager.tsx +209 -0
- package/src/client/components/MessageInput.tsx +263 -0
- package/src/client/components/OAuthConsent.tsx +103 -0
- package/src/client/components/SchedulesManager.tsx +99 -0
- package/src/client/components/Sidebar.tsx +235 -0
- package/src/client/components/SkillsManager.tsx +280 -0
- package/src/client/components/SmartChart.tsx +167 -0
- package/src/client/components/Toast.tsx +54 -0
- package/src/client/components/WorkspaceTree.tsx +313 -0
- package/src/client/components/ZoomableImage.tsx +123 -0
- package/src/client/components/artifact-presets.ts +10 -0
- package/src/client/components/schedules/ScheduleEditor.tsx +264 -0
- package/src/client/components/schedules/ScheduleList.tsx +271 -0
- package/src/client/components/ui/accordion.tsx +50 -0
- package/src/client/components/ui/button.tsx +43 -0
- package/src/client/components/ui/dialog.tsx +82 -0
- package/src/client/components/ui/input.tsx +19 -0
- package/src/client/components/ui/scroll-area.tsx +39 -0
- package/src/client/components/ui/textarea.tsx +18 -0
- package/src/client/globals.css +51 -0
- package/src/client/hooks/useAgentSocket.ts +79 -0
- package/src/client/hooks/useArtifacts.ts +89 -0
- package/src/client/hooks/useAuth.ts +127 -0
- package/src/client/hooks/useConversation.ts +412 -0
- package/src/client/hooks/useDarkMode.ts +57 -0
- package/src/client/hooks/useIsMobile.ts +23 -0
- package/src/client/hooks/usePush.ts +127 -0
- package/src/client/hooks/useSchedules.ts +73 -0
- package/src/client/hooks/useUnread.ts +238 -0
- package/src/client/lib/desktopAttention.ts +75 -0
- package/src/client/lib/firebase.ts +32 -0
- package/src/client/lib/googleAuthNative.ts +94 -0
- package/src/client/lib/native.ts +43 -0
- package/src/client/lib/schedule-types.ts +34 -0
- package/src/client/lib/sessionApi.ts +58 -0
- package/src/client/lib/slots.tsx +79 -0
- package/src/client/lib/storage.ts +39 -0
- package/src/client/lib/utils.ts +26 -0
- package/src/client/lib/workspaceContext.tsx +54 -0
- package/src/client/lib/ws.ts +203 -0
- package/src/client/main.tsx +14 -0
- package/src/mcp-stdio-bridge.ts +70 -0
- package/src/scripts/summarize-conversations.ts +5 -0
- package/src/scripts/typecheck.ts +43 -0
- package/src/server/agents.ts +54 -0
- package/src/server/api-keys.ts +63 -0
- package/src/server/artifacts/artifacts.export.ts +85 -0
- package/src/server/artifacts/artifacts.handler.ts +93 -0
- package/src/server/artifacts/artifacts.routes.ts +43 -0
- package/src/server/artifacts/artifacts.service.ts +100 -0
- package/src/server/artifacts/artifacts.types.ts +31 -0
- package/src/server/auth.ts +262 -0
- package/src/server/claude.ts +394 -0
- package/src/server/commands.ts +21 -0
- package/src/server/contacts.ts +177 -0
- package/src/server/conversation-summarizer.ts +204 -0
- package/src/server/data-sync.ts +664 -0
- package/src/server/directives.ts +91 -0
- package/src/server/engine/claude-code.ts +514 -0
- package/src/server/engine/index.ts +41 -0
- package/src/server/engine/registry.ts +21 -0
- package/src/server/engine/shared.ts +47 -0
- package/src/server/engine/types.ts +48 -0
- package/src/server/env-resolve.ts +71 -0
- package/src/server/env-sanitize.ts +9 -0
- package/src/server/events/bus.ts +29 -0
- package/src/server/events/dispatcher.ts +48 -0
- package/src/server/events/routes.ts +19 -0
- package/src/server/events/types.ts +9 -0
- package/src/server/extensions.ts +101 -0
- package/src/server/features.ts +109 -0
- package/src/server/file-inject.ts +45 -0
- package/src/server/hooks.ts +142 -0
- package/src/server/idempotency.ts +25 -0
- package/src/server/index.ts +1715 -0
- package/src/server/integrity-audit.ts +132 -0
- package/src/server/mcp-catalog.ts +70 -0
- package/src/server/mcp-oauth.ts +198 -0
- package/src/server/mcp-progress.ts +45 -0
- package/src/server/mcp-server.ts +456 -0
- package/src/server/mcp-sidecar.ts +87 -0
- package/src/server/mcp.ts +291 -0
- package/src/server/model-aliases.ts +76 -0
- package/src/server/paths.ts +24 -0
- package/src/server/polls.ts +175 -0
- package/src/server/push/apns.ts +113 -0
- package/src/server/push/fcm.ts +108 -0
- package/src/server/push/push.ts +66 -0
- package/src/server/push/store.ts +84 -0
- package/src/server/push/triggers.ts +99 -0
- package/src/server/scheduler/builtins.ts +157 -0
- package/src/server/scheduler/engine.ts +432 -0
- package/src/server/scheduler/index.ts +4 -0
- package/src/server/scheduler/runner.ts +334 -0
- package/src/server/scheduler/storage.ts +98 -0
- package/src/server/scheduler/timing.ts +70 -0
- package/src/server/scheduler/types.ts +62 -0
- package/src/server/sdk-utils.ts +45 -0
- package/src/server/seed.ts +174 -0
- package/src/server/session-bus.ts +18 -0
- package/src/server/sessions.ts +559 -0
- package/src/server/shraga-config.ts +167 -0
- package/src/server/skills.ts +372 -0
- package/src/server/slack/api.ts +37 -0
- package/src/server/slack/bot.ts +391 -0
- package/src/server/slack/context-cache.ts +42 -0
- package/src/server/slack/feature.ts +59 -0
- package/src/server/slack/mention-rewrite.ts +59 -0
- package/src/server/slack/oauth.ts +102 -0
- package/src/server/slack/questions.ts +112 -0
- package/src/server/slack/sessions.ts +139 -0
- package/src/server/stats.ts +106 -0
- package/src/server/summarize.ts +11 -0
- package/src/server/turn-context.ts +61 -0
- package/src/server/unclaw-config.ts +19 -0
- package/src/server/unread.ts +79 -0
- package/src/server/user-context.ts +33 -0
- package/src/server/vendor-sync.ts +52 -0
- package/src/server/voice-provider.ts +74 -0
- package/src/server/workspace.ts +249 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { resolve, dirname } from 'node:path';
|
|
3
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
4
|
+
import { json } from 'itty-router';
|
|
5
|
+
import { RouterWrapper, captureMcpProgress } from 'edge.libx.js/build/main.js';
|
|
6
|
+
import type { Application, Request, Response } from 'express';
|
|
7
|
+
|
|
8
|
+
import { listWorkspaceTree, readWorkspaceFile, safeResolve, searchWorkspace } from './workspace.ts';
|
|
9
|
+
import { listSkills, getSkill, saveSkill } from './skills.ts';
|
|
10
|
+
import { getAllSessions, loadConversation, isSessionLocked } from './sessions.ts';
|
|
11
|
+
import * as scheduler from './scheduler/index.ts';
|
|
12
|
+
import { getAgentConfig } from './claude.ts';
|
|
13
|
+
import { validateApiKey } from './api-keys.ts';
|
|
14
|
+
import { verifyMcpToken } from './auth.ts';
|
|
15
|
+
import { makeProgressEmitter } from './mcp-progress.ts';
|
|
16
|
+
import { lookupIdempotent, rememberIdempotent } from './idempotency.ts';
|
|
17
|
+
import type { WsEvent } from './claude.ts';
|
|
18
|
+
|
|
19
|
+
export type RunChatTurnResult =
|
|
20
|
+
| { status: 'busy' }
|
|
21
|
+
| { sessionId: string; text: string; blocks: unknown[] }
|
|
22
|
+
| { sessionId: string; error: string };
|
|
23
|
+
|
|
24
|
+
export type RunChatTurn = (
|
|
25
|
+
opts: {
|
|
26
|
+
prompt: string;
|
|
27
|
+
sessionId?: string;
|
|
28
|
+
uid: string;
|
|
29
|
+
userEmail: string;
|
|
30
|
+
userName?: string;
|
|
31
|
+
abortController?: AbortController;
|
|
32
|
+
context?: Record<string, string>;
|
|
33
|
+
},
|
|
34
|
+
hooks?: { onEvent?: (ev: WsEvent) => void },
|
|
35
|
+
) => Promise<RunChatTurnResult>;
|
|
36
|
+
|
|
37
|
+
export interface McpServerDeps {
|
|
38
|
+
runChatTurn: RunChatTurn;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Per-request caller identity, set by the /mcp Express handler before forwarding to MCP tool handlers.
|
|
42
|
+
// Safe because Node is single-threaded and the handler awaits the full MCP response.
|
|
43
|
+
let currentCaller: { uid: string; email: string } | null = null;
|
|
44
|
+
|
|
45
|
+
function errMessage(e: unknown): string {
|
|
46
|
+
return e instanceof Error ? e.message : String(e);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function str(v: unknown): string | undefined {
|
|
50
|
+
if (v === undefined || v === null || String(v) === '') return undefined;
|
|
51
|
+
return String(v);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createShragaMcp(deps: McpServerDeps) {
|
|
55
|
+
const base = RouterWrapper.getNew('', {
|
|
56
|
+
origin: '*',
|
|
57
|
+
allowMethods: ['GET', 'POST', 'PUT', 'OPTIONS'],
|
|
58
|
+
allowHeaders: ['Content-Type', 'Authorization'],
|
|
59
|
+
});
|
|
60
|
+
const { router } = base;
|
|
61
|
+
const allTools = process.env.MCP_ALL_TOOLS === 'true';
|
|
62
|
+
|
|
63
|
+
// ── Sessions, Workspace, Skills, Schedules ──────────────────────────────
|
|
64
|
+
// Disabled by default — set MCP_ALL_TOOLS=true to expose (the agent can do everything via chat)
|
|
65
|
+
|
|
66
|
+
if (allTools) {
|
|
67
|
+
|
|
68
|
+
base.describeMCP('/sessions', 'GET', {
|
|
69
|
+
description: 'List conversations. Returns session metadata (id, title, user, timestamps, status). Use limit/offset for pagination.',
|
|
70
|
+
params: {
|
|
71
|
+
limit: { description: 'Max results (default 50)', type: 'string' },
|
|
72
|
+
offset: { description: 'Skip N results', type: 'string' },
|
|
73
|
+
},
|
|
74
|
+
annotations: { readOnlyHint: true },
|
|
75
|
+
});
|
|
76
|
+
router.get('/sessions', async (req: any) => {
|
|
77
|
+
try {
|
|
78
|
+
const limit = parseInt(str(req.query.limit) ?? '50', 10);
|
|
79
|
+
const offset = parseInt(str(req.query.offset) ?? '0', 10);
|
|
80
|
+
const sorted = [...getAllSessions()].sort((a, b) => (b.lastModified ?? b.createdAt) - (a.lastModified ?? a.createdAt));
|
|
81
|
+
const page = sorted.slice(offset, offset + limit);
|
|
82
|
+
return json({ sessions: page, total: sorted.length });
|
|
83
|
+
} catch (e) { return json({ error: errMessage(e) }, { status: 500 }); }
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
base.describeMCP('/sessions/messages', 'GET', {
|
|
87
|
+
description: 'Read conversation messages for a session. Returns structured blocks (text, tool_use, tool_result).',
|
|
88
|
+
params: {
|
|
89
|
+
sessionId: { description: 'Session ID (required)', type: 'string', required: true },
|
|
90
|
+
limit: { description: 'Max messages (default 50, from end)', type: 'string' },
|
|
91
|
+
},
|
|
92
|
+
annotations: { readOnlyHint: true },
|
|
93
|
+
});
|
|
94
|
+
router.get('/sessions/messages', async (req: any) => {
|
|
95
|
+
try {
|
|
96
|
+
const sid = str(req.query.sessionId);
|
|
97
|
+
if (!sid) return json({ error: 'sessionId required' }, { status: 400 });
|
|
98
|
+
const messages = loadConversation(sid);
|
|
99
|
+
const limit = parseInt(str(req.query.limit) ?? '50', 10);
|
|
100
|
+
const sliced = messages.slice(-limit);
|
|
101
|
+
return json({ sessionId: sid, messages: sliced, total: messages.length });
|
|
102
|
+
} catch (e) { return json({ error: errMessage(e) }, { status: 500 }); }
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ── Workspace ────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
base.describeMCP('/workspace', 'GET', {
|
|
108
|
+
description: 'List workspace file tree. Returns paths, types, sizes, and one-line summaries.',
|
|
109
|
+
annotations: { readOnlyHint: true },
|
|
110
|
+
});
|
|
111
|
+
router.get('/workspace', async () => {
|
|
112
|
+
try {
|
|
113
|
+
return json({ entries: listWorkspaceTree() });
|
|
114
|
+
} catch (e) { return json({ error: errMessage(e) }, { status: 500 }); }
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
base.describeMCP('/workspace/file', 'GET', {
|
|
118
|
+
description: 'Read a workspace file by relative path. Returns content as text.',
|
|
119
|
+
params: {
|
|
120
|
+
path: { description: 'Relative path within workspace (required)', type: 'string', required: true },
|
|
121
|
+
},
|
|
122
|
+
annotations: { readOnlyHint: true },
|
|
123
|
+
});
|
|
124
|
+
router.get('/workspace/file', async (req: any) => {
|
|
125
|
+
try {
|
|
126
|
+
const rel = str(req.query.path);
|
|
127
|
+
if (!rel) return json({ error: 'path required' }, { status: 400 });
|
|
128
|
+
const result = readWorkspaceFile(rel);
|
|
129
|
+
if (!result) return json({ error: 'Not found' }, { status: 404 });
|
|
130
|
+
return json(result);
|
|
131
|
+
} catch (e) { return json({ error: errMessage(e) }, { status: 500 }); }
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
base.describeMCP('/workspace/file', 'PUT', {
|
|
135
|
+
description: 'Write or update a workspace file. Creates parent directories as needed.',
|
|
136
|
+
params: {
|
|
137
|
+
body: { description: '{ path: "relative/path.md", content: "file content" }', type: 'object' },
|
|
138
|
+
},
|
|
139
|
+
annotations: { destructiveHint: false },
|
|
140
|
+
});
|
|
141
|
+
router.put('/workspace/file', async (req: any) => {
|
|
142
|
+
try {
|
|
143
|
+
const body = await req.json();
|
|
144
|
+
const { path: rel, content } = body as { path?: string; content?: string };
|
|
145
|
+
if (!rel || content === undefined) return json({ error: 'path and content required' }, { status: 400 });
|
|
146
|
+
const resolved = safeResolve(rel);
|
|
147
|
+
if (!resolved) return json({ error: 'Invalid path' }, { status: 400 });
|
|
148
|
+
mkdirSync(dirname(resolved), { recursive: true });
|
|
149
|
+
writeFileSync(resolved, content);
|
|
150
|
+
return json({ ok: true, path: rel });
|
|
151
|
+
} catch (e) { return json({ error: errMessage(e) }, { status: 500 }); }
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
base.describeMCP('/workspace/search', 'GET', {
|
|
155
|
+
description: 'Search workspace files for a text query. Returns matching file paths, line numbers, and snippets.',
|
|
156
|
+
params: {
|
|
157
|
+
query: { description: 'Search text (case-insensitive, required)', type: 'string', required: true },
|
|
158
|
+
maxResults: { description: 'Max matches (default 50)', type: 'string' },
|
|
159
|
+
},
|
|
160
|
+
annotations: { readOnlyHint: true },
|
|
161
|
+
});
|
|
162
|
+
router.get('/workspace/search', async (req: any) => {
|
|
163
|
+
try {
|
|
164
|
+
const query = str(req.query.query);
|
|
165
|
+
if (!query) return json({ error: 'query required' }, { status: 400 });
|
|
166
|
+
const max = parseInt(str(req.query.maxResults) ?? '50', 10);
|
|
167
|
+
const matches = searchWorkspace(query, max);
|
|
168
|
+
return json({ query, matches, total: matches.length });
|
|
169
|
+
} catch (e) { return json({ error: errMessage(e) }, { status: 500 }); }
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ── Skills ───────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
base.describeMCP('/skills', 'GET', {
|
|
175
|
+
description: 'List available agent skills (markdown prompt templates). Returns names and metadata.',
|
|
176
|
+
annotations: { readOnlyHint: true },
|
|
177
|
+
});
|
|
178
|
+
router.get('/skills', async () => {
|
|
179
|
+
try {
|
|
180
|
+
const names = listSkills();
|
|
181
|
+
const skills = names.map(name => {
|
|
182
|
+
const s = getSkill(name);
|
|
183
|
+
return s ? { name: s.name, builtin: s.builtin, meta: s.meta } : { name };
|
|
184
|
+
});
|
|
185
|
+
return json({ skills });
|
|
186
|
+
} catch (e) { return json({ error: errMessage(e) }, { status: 500 }); }
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
base.describeMCP('/skills/read', 'GET', {
|
|
190
|
+
description: 'Read full content of a skill by name.',
|
|
191
|
+
params: {
|
|
192
|
+
name: { description: 'Skill name (required)', type: 'string', required: true },
|
|
193
|
+
},
|
|
194
|
+
annotations: { readOnlyHint: true },
|
|
195
|
+
});
|
|
196
|
+
router.get('/skills/read', async (req: any) => {
|
|
197
|
+
try {
|
|
198
|
+
const name = str(req.query.name);
|
|
199
|
+
if (!name) return json({ error: 'name required' }, { status: 400 });
|
|
200
|
+
const skill = getSkill(name);
|
|
201
|
+
if (!skill) return json({ error: 'Skill not found' }, { status: 404 });
|
|
202
|
+
return json(skill);
|
|
203
|
+
} catch (e) { return json({ error: errMessage(e) }, { status: 500 }); }
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
base.describeMCP('/skills/write', 'PUT', {
|
|
207
|
+
description: 'Create or update a skill. Cannot modify built-in skills.',
|
|
208
|
+
params: {
|
|
209
|
+
body: { description: '{ name: "skill-name", content: "markdown content" }', type: 'object' },
|
|
210
|
+
},
|
|
211
|
+
annotations: { destructiveHint: false },
|
|
212
|
+
});
|
|
213
|
+
router.put('/skills/write', async (req: any) => {
|
|
214
|
+
try {
|
|
215
|
+
const body = await req.json();
|
|
216
|
+
const { name, content } = body as { name?: string; content?: string };
|
|
217
|
+
if (!name || !content) return json({ error: 'name and content required' }, { status: 400 });
|
|
218
|
+
saveSkill(name, content);
|
|
219
|
+
return json({ ok: true, name });
|
|
220
|
+
} catch (e) { return json({ error: errMessage(e) }, { status: 500 }); }
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ── Schedules ────────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
base.describeMCP('/schedules', 'GET', {
|
|
226
|
+
description: 'List scheduled jobs. Returns schedule definitions with trigger, task, status, and run history.',
|
|
227
|
+
annotations: { readOnlyHint: true },
|
|
228
|
+
});
|
|
229
|
+
router.get('/schedules', async () => {
|
|
230
|
+
try {
|
|
231
|
+
const schedules = scheduler.listSchedules();
|
|
232
|
+
return json({ schedules });
|
|
233
|
+
} catch (e) { return json({ error: errMessage(e) }, { status: 500 }); }
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
base.describeMCP('/schedules/run', 'POST', {
|
|
237
|
+
description: 'Trigger an immediate run of a schedule by ID.',
|
|
238
|
+
params: {
|
|
239
|
+
body: { description: '{ id: "schedule-id" }', type: 'object' },
|
|
240
|
+
},
|
|
241
|
+
annotations: { destructiveHint: false },
|
|
242
|
+
});
|
|
243
|
+
router.post('/schedules/run', async (req: any) => {
|
|
244
|
+
try {
|
|
245
|
+
const body = await req.json();
|
|
246
|
+
const { id } = body as { id?: string };
|
|
247
|
+
if (!id) return json({ error: 'id required' }, { status: 400 });
|
|
248
|
+
const schedule = scheduler.getSchedule(id);
|
|
249
|
+
if (!schedule) return json({ error: 'Schedule not found' }, { status: 404 });
|
|
250
|
+
scheduler.runNow(id);
|
|
251
|
+
return json({ ok: true, id });
|
|
252
|
+
} catch (e) { return json({ error: errMessage(e) }, { status: 500 }); }
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
} // end if (allTools)
|
|
256
|
+
|
|
257
|
+
// ── Chat ─────────────────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
base.describeMCP('/chat', 'POST', {
|
|
260
|
+
description: 'Talk to the Shraga agent — like a conversation. Send a prompt, get a full response. The agent has access to any configured MCP tools, workspace files, and skills. To continue a conversation, pass the sessionId from a previous response. Without sessionId, starts a new conversation. For work expected to exceed ~60s (multi-tool, long reads, reports), pass sync: false — it returns { sessionId, status: "accepted" } immediately. Then poll the UI or get_sessions for that sessionId. Do NOT re-submit the prompt (via /api/chat or a second post_chat) after a client-side timeout — the turn is already running; that only creates duplicate sessions.',
|
|
261
|
+
params: {
|
|
262
|
+
body: { description: '{ prompt: "your message", sessionId?: "continue a conversation", sync?: "wait for response (default true) — set false to fire-and-forget and get the sessionId back immediately for long tasks", clientRequestId?: "idempotency key — a retry with the same id within 15min reuses the same session (returns status: duplicate) instead of creating a new one" }', type: 'object' },
|
|
263
|
+
},
|
|
264
|
+
annotations: { destructiveHint: false },
|
|
265
|
+
});
|
|
266
|
+
router.post('/chat', async (req: any) => {
|
|
267
|
+
try {
|
|
268
|
+
const body = await req.json();
|
|
269
|
+
const { prompt, sessionId, sync = true, clientRequestId } = body as { prompt?: string; sessionId?: string; sync?: boolean; clientRequestId?: string };
|
|
270
|
+
if (!prompt) return json({ error: 'prompt required' }, { status: 400 });
|
|
271
|
+
|
|
272
|
+
const caller = currentCaller || { uid: 'agent-internal', email: 'agent@internal' };
|
|
273
|
+
const idemKey = clientRequestId || str(req.headers?.get?.('idempotency-key'));
|
|
274
|
+
|
|
275
|
+
// Idempotency: a retried submit with the same key reuses the session that first
|
|
276
|
+
// handled it (within TTL) instead of spawning a duplicate.
|
|
277
|
+
if (idemKey) {
|
|
278
|
+
const existing = lookupIdempotent(caller.uid, idemKey);
|
|
279
|
+
if (existing) return json({ sessionId: existing, status: 'duplicate' });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Pre-allocate the session id so async callers get a real id back immediately
|
|
283
|
+
// (parity with POST /api/chat). Lets timeout-prone MCP clients recover/continue
|
|
284
|
+
// instead of double-submitting and creating duplicate sessions.
|
|
285
|
+
const sid = sessionId || `api-${crypto.randomUUID()}`;
|
|
286
|
+
if (idemKey) rememberIdempotent(caller.uid, idemKey, sid);
|
|
287
|
+
const turn = { prompt, sessionId: sid, uid: caller.uid, userEmail: caller.email, context: { source: 'mcp', user: caller.email } };
|
|
288
|
+
|
|
289
|
+
if (sync === false) {
|
|
290
|
+
// Reject a duplicate before responding 'accepted' (lock is acquired inside the turn).
|
|
291
|
+
if (sessionId && isSessionLocked(sid)) {
|
|
292
|
+
return json({ error: 'Session is already processing a request' }, { status: 409 });
|
|
293
|
+
}
|
|
294
|
+
// Fire-and-forget: kick off the turn, return the real session id immediately.
|
|
295
|
+
void deps.runChatTurn(turn);
|
|
296
|
+
return json({ sessionId: sid, status: 'accepted' });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Capture the progress channel here (within the tools/call async context) and stream
|
|
300
|
+
// agent events as notifications/progress. No-op unless the client requested progress.
|
|
301
|
+
const result = await deps.runChatTurn(turn, { onEvent: makeProgressEmitter(captureMcpProgress()) });
|
|
302
|
+
if ('status' in result) return json({ error: 'Session is already processing a request' }, { status: 409 });
|
|
303
|
+
if ('error' in result) return json({ error: result.error, sessionId: result.sessionId }, { status: 500 });
|
|
304
|
+
return json({ sessionId: result.sessionId, text: result.text, blocks: result.blocks });
|
|
305
|
+
} catch (e) { return json({ error: errMessage(e) }, { status: 500 }); }
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ── Config ───────────────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
base.describeMCP('/config', 'GET', {
|
|
311
|
+
description: 'Get agent configuration (model, defaults, etc.).',
|
|
312
|
+
annotations: { readOnlyHint: true },
|
|
313
|
+
});
|
|
314
|
+
router.get('/config', async () => {
|
|
315
|
+
try {
|
|
316
|
+
return json(getAgentConfig());
|
|
317
|
+
} catch (e) { return json({ error: errMessage(e) }, { status: 500 }); }
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ── Finalize ─────────────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
base.catchNotFound();
|
|
323
|
+
|
|
324
|
+
const mcp = base.asMCP({
|
|
325
|
+
name: 'shraga',
|
|
326
|
+
version: '0.1.0',
|
|
327
|
+
instructions: [
|
|
328
|
+
'Shraga agent MCP — talk to the agent via post_chat.',
|
|
329
|
+
'The agent has access to the workspace, skills, sessions, schedules, and any configured MCP tools.',
|
|
330
|
+
'Send a prompt to start a new conversation; pass sessionId to continue one.',
|
|
331
|
+
'Auth: pass API key as Bearer token.',
|
|
332
|
+
].join(' '),
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// Augment with skill resource — the shipped MCP guide, which is also seeded into data/skills/.
|
|
336
|
+
const skillPath = resolve(import.meta.dirname, '../../defaults/skills/mcp-server.md');
|
|
337
|
+
augmentWithSkillResource(mcp, skillPath);
|
|
338
|
+
|
|
339
|
+
return { mcp, base };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function augmentWithSkillResource(adapter: any, skillPath: string) {
|
|
343
|
+
const canonicalUri = 'skill://shraga/workflow';
|
|
344
|
+
const orig = adapter.handleJsonRpc.bind(adapter);
|
|
345
|
+
|
|
346
|
+
adapter.handleJsonRpc = async (message: any, sendNotification?: (n: unknown) => void): Promise<unknown> => {
|
|
347
|
+
const { method, id, params } = message;
|
|
348
|
+
|
|
349
|
+
if (method === 'resources/list') {
|
|
350
|
+
return {
|
|
351
|
+
jsonrpc: '2.0', id,
|
|
352
|
+
result: {
|
|
353
|
+
resources: [{
|
|
354
|
+
uri: canonicalUri,
|
|
355
|
+
name: 'shraga-workflow',
|
|
356
|
+
description: 'Shraga agent MCP workflow guide',
|
|
357
|
+
mimeType: 'text/markdown',
|
|
358
|
+
}],
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (method === 'resources/read') {
|
|
364
|
+
const uri = params?.uri;
|
|
365
|
+
if (uri !== canonicalUri) return { jsonrpc: '2.0', id, error: { code: -32602, message: `Unknown resource: ${uri}` } };
|
|
366
|
+
if (!existsSync(skillPath)) return { jsonrpc: '2.0', id, error: { code: -32603, message: 'Skill file not found' } };
|
|
367
|
+
return {
|
|
368
|
+
jsonrpc: '2.0', id,
|
|
369
|
+
result: { contents: [{ uri: canonicalUri, mimeType: 'text/markdown', text: readFileSync(skillPath, 'utf-8') }] },
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const res = await orig(message, sendNotification) as any;
|
|
374
|
+
if (method === 'initialize' && res?.result?.capabilities) {
|
|
375
|
+
res.result.capabilities.resources = { subscribe: false, ...(typeof res.result.capabilities.resources === 'object' ? res.result.capabilities.resources : {}) };
|
|
376
|
+
}
|
|
377
|
+
return res;
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Mount the MCP server on an Express app at /mcp. */
|
|
382
|
+
export function mountMcpServer(app: Application, deps: McpServerDeps) {
|
|
383
|
+
const { mcp } = createShragaMcp(deps);
|
|
384
|
+
|
|
385
|
+
app.all('/mcp', async (req: Request, res: Response) => {
|
|
386
|
+
// Auth: require API key or internal token
|
|
387
|
+
const authHeader = req.headers.authorization?.replace('Bearer ', '');
|
|
388
|
+
const internalToken = req.headers['x-internal-token'] as string | undefined;
|
|
389
|
+
|
|
390
|
+
let authed = false;
|
|
391
|
+
let caller: { uid: string; email: string } | null = null;
|
|
392
|
+
if (internalToken && process.env.INTERNAL_API_TOKEN) {
|
|
393
|
+
const secret = process.env.INTERNAL_API_TOKEN;
|
|
394
|
+
authed = internalToken.length === secret.length &&
|
|
395
|
+
timingSafeEqual(Buffer.from(internalToken), Buffer.from(secret));
|
|
396
|
+
}
|
|
397
|
+
if (!authed && authHeader?.startsWith('uck_')) {
|
|
398
|
+
caller = validateApiKey(authHeader);
|
|
399
|
+
authed = !!caller;
|
|
400
|
+
}
|
|
401
|
+
if (!authed && authHeader?.startsWith('mcp_')) {
|
|
402
|
+
const id = verifyMcpToken(authHeader);
|
|
403
|
+
if (id && id.kind === 'access') { caller = { uid: id.uid, email: id.email }; authed = true; }
|
|
404
|
+
}
|
|
405
|
+
if (!authed) {
|
|
406
|
+
// Point MCP clients (claude.ai et al.) at our OAuth discovery so they can run the auth handshake.
|
|
407
|
+
const proto = (req.get('x-forwarded-proto') || req.protocol).split(',')[0];
|
|
408
|
+
const base = `${proto}://${req.get('host')}`;
|
|
409
|
+
res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource"`);
|
|
410
|
+
return void res.status(401).json({ error: 'Unauthorized — provide API key or complete OAuth' });
|
|
411
|
+
}
|
|
412
|
+
currentCaller = caller;
|
|
413
|
+
|
|
414
|
+
// Bridge Express Request → Web API Request → MCPAdapter.httpHandler → Express Response.
|
|
415
|
+
const url = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
|
|
416
|
+
const webReq = new globalThis.Request(url, {
|
|
417
|
+
method: req.method,
|
|
418
|
+
headers: Object.fromEntries(
|
|
419
|
+
Object.entries(req.headers)
|
|
420
|
+
.filter(([, v]) => typeof v === 'string')
|
|
421
|
+
.map(([k, v]) => [k, v as string]),
|
|
422
|
+
),
|
|
423
|
+
...(req.method !== 'GET' && req.method !== 'HEAD' ? { body: JSON.stringify(req.body) } : {}),
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
const webRes: globalThis.Response = await mcp.httpHandler(webReq);
|
|
428
|
+
res.status(webRes.status);
|
|
429
|
+
webRes.headers.forEach((val, key) => res.setHeader(key, val));
|
|
430
|
+
// For a Streamable-HTTP SSE response (progress streaming), pipe the body so frames flush
|
|
431
|
+
// as the agent emits them. currentCaller stays set until the pipe completes (the tool
|
|
432
|
+
// handler reads it inside the stream). Otherwise buffer the single JSON response.
|
|
433
|
+
if (webRes.body && (webRes.headers.get('content-type') || '').includes('text/event-stream')) {
|
|
434
|
+
(res as any).flushHeaders?.();
|
|
435
|
+
const reader = webRes.body.getReader();
|
|
436
|
+
for (;;) {
|
|
437
|
+
const { value, done } = await reader.read();
|
|
438
|
+
if (done) break;
|
|
439
|
+
res.write(Buffer.from(value));
|
|
440
|
+
(res as any).flush?.();
|
|
441
|
+
}
|
|
442
|
+
res.end();
|
|
443
|
+
} else {
|
|
444
|
+
res.send(await webRes.text());
|
|
445
|
+
}
|
|
446
|
+
} catch (e) {
|
|
447
|
+
console.error('[mcp-server] handler error:', e);
|
|
448
|
+
if (!res.headersSent) res.status(500).json({ error: 'MCP handler error' });
|
|
449
|
+
else res.end();
|
|
450
|
+
} finally {
|
|
451
|
+
currentCaller = null;
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
console.log('[mcp-server] MCP endpoint mounted at /mcp');
|
|
456
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getHttpSidecarSpecs, type HttpSidecarSpec } from './shraga-config.ts';
|
|
4
|
+
|
|
5
|
+
const PROJECT_ROOT = path.resolve(import.meta.dirname, '..', '..');
|
|
6
|
+
const sidecars = new Map<string, { proc: ChildProcess; spec: HttpSidecarSpec }>();
|
|
7
|
+
|
|
8
|
+
async function isPortAlive(url: string): Promise<boolean> {
|
|
9
|
+
try {
|
|
10
|
+
const res = await fetch(url.replace('/mcp', '/health'), { signal: AbortSignal.timeout(2000) });
|
|
11
|
+
return res.ok;
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let shuttingDown = false;
|
|
18
|
+
|
|
19
|
+
function startOne(spec: HttpSidecarSpec, restarts = 0) {
|
|
20
|
+
const vendorDir = path.join(PROJECT_ROOT, 'vendor', spec.dir);
|
|
21
|
+
const entrypoint = path.join(vendorDir, 'src/mcp/cli.ts');
|
|
22
|
+
const args = ['run', entrypoint, '--port', String(spec.port)];
|
|
23
|
+
const startedAt = Date.now();
|
|
24
|
+
|
|
25
|
+
const proc = spawn('bun', args, {
|
|
26
|
+
cwd: vendorDir,
|
|
27
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
28
|
+
env: { ...process.env },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
proc.stdout?.on('data', (d: Buffer) => {
|
|
32
|
+
for (const line of d.toString().trim().split('\n')) {
|
|
33
|
+
if (line) console.log(`[sidecar:${spec.name}] ${line}`);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
proc.stderr?.on('data', (d: Buffer) => {
|
|
37
|
+
for (const line of d.toString().trim().split('\n')) {
|
|
38
|
+
if (line) console.log(`[sidecar:${spec.name}] ${line}`);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
proc.on('exit', (code, signal) => {
|
|
43
|
+
console.log(`[sidecar:${spec.name}] exited code=${code} signal=${signal}`);
|
|
44
|
+
sidecars.delete(spec.name);
|
|
45
|
+
if (shuttingDown) return;
|
|
46
|
+
// Auto-restart on crash (KeepAlive-style). Exponential backoff, capped; reset after a stable run.
|
|
47
|
+
const next = Date.now() - startedAt > 60_000 ? 0 : restarts + 1;
|
|
48
|
+
const delay = Math.min(30_000, 1_000 * 2 ** Math.min(next, 5));
|
|
49
|
+
console.log(`[sidecar:${spec.name}] restarting in ${delay}ms (#${next})`);
|
|
50
|
+
setTimeout(() => {
|
|
51
|
+
if (!shuttingDown && !sidecars.has(spec.name)) startOne(spec, next);
|
|
52
|
+
}, delay);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
sidecars.set(spec.name, { proc, spec });
|
|
56
|
+
console.log(`[sidecar] started ${spec.name} pid=${proc.pid} on port ${spec.port}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function startSidecars() {
|
|
60
|
+
const specs = getHttpSidecarSpecs();
|
|
61
|
+
for (const spec of specs) {
|
|
62
|
+
if (sidecars.has(spec.name)) continue;
|
|
63
|
+
if (await isPortAlive(spec.url)) {
|
|
64
|
+
console.log(`[sidecar] ${spec.name} already running on port ${spec.port}`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
startOne(spec);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
console.error(`[sidecar] failed to start ${spec.name}:`, e instanceof Error ? e.message : String(e));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function stopSidecars() {
|
|
76
|
+
shuttingDown = true;
|
|
77
|
+
for (const [name, { proc }] of sidecars) {
|
|
78
|
+
console.log(`[sidecar] stopping ${name} pid=${proc.pid}`);
|
|
79
|
+
try { proc.kill('SIGTERM'); } catch {}
|
|
80
|
+
}
|
|
81
|
+
setTimeout(() => {
|
|
82
|
+
for (const [, { proc }] of sidecars) {
|
|
83
|
+
try { proc.kill('SIGKILL'); } catch {}
|
|
84
|
+
}
|
|
85
|
+
sidecars.clear();
|
|
86
|
+
}, 3000);
|
|
87
|
+
}
|