@tapicker/bridge 1.0.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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/dist/bridge/bridge-service.d.ts +46 -0
  4. package/dist/bridge/bridge-service.js +245 -0
  5. package/dist/bridge/control.d.ts +7 -0
  6. package/dist/bridge/control.js +211 -0
  7. package/dist/bridge/daemon.d.ts +16 -0
  8. package/dist/bridge/daemon.js +97 -0
  9. package/dist/bridge/extension-session.d.ts +43 -0
  10. package/dist/bridge/extension-session.js +204 -0
  11. package/dist/bridge/pairing-state.d.ts +22 -0
  12. package/dist/bridge/pairing-state.js +76 -0
  13. package/dist/cli.d.ts +3 -0
  14. package/dist/cli.js +835 -0
  15. package/dist/commands.d.ts +33 -0
  16. package/dist/commands.js +123 -0
  17. package/dist/constants.d.ts +10 -0
  18. package/dist/constants.js +10 -0
  19. package/dist/errors.d.ts +27 -0
  20. package/dist/errors.js +52 -0
  21. package/dist/index.d.ts +10 -0
  22. package/dist/index.js +8 -0
  23. package/dist/mcp.d.ts +13 -0
  24. package/dist/mcp.js +300 -0
  25. package/dist/protocol/schema.d.ts +149 -0
  26. package/dist/protocol/schema.js +155 -0
  27. package/dist/server.d.ts +63 -0
  28. package/dist/server.js +486 -0
  29. package/dist/workflow-watch.d.ts +31 -0
  30. package/dist/workflow-watch.js +304 -0
  31. package/docs/cli.md +112 -0
  32. package/docs/http-api.md +149 -0
  33. package/docs/mcp.md +90 -0
  34. package/docs/operations.md +85 -0
  35. package/docs/workflow-watch.md +86 -0
  36. package/package.json +55 -0
  37. package/src/bridge/bridge-service.ts +291 -0
  38. package/src/bridge/control.ts +281 -0
  39. package/src/bridge/daemon.ts +102 -0
  40. package/src/bridge/extension-session.ts +286 -0
  41. package/src/bridge/pairing-state.ts +92 -0
  42. package/src/cli.ts +1019 -0
  43. package/src/commands.ts +158 -0
  44. package/src/constants.ts +10 -0
  45. package/src/errors.ts +72 -0
  46. package/src/index.ts +35 -0
  47. package/src/mcp.ts +386 -0
  48. package/src/protocol/schema.ts +202 -0
  49. package/src/server.ts +623 -0
  50. package/src/workflow-watch.ts +364 -0
@@ -0,0 +1,158 @@
1
+ import { z } from 'zod'
2
+ import { DEFAULT_WORKFLOW_WATCH_WS_PATH } from './constants.js'
3
+ import { BridgeError } from './errors.js'
4
+ import {
5
+ WorkflowControlInputSchema,
6
+ DataInputSchema,
7
+ WorkflowStartInputSchema,
8
+ WorkflowStartResultSchema,
9
+ WorkflowStatusDataSchema,
10
+ WorkflowWatchRequestSchema,
11
+ } from './protocol/schema.js'
12
+ import type { BridgeService } from './bridge/bridge-service.js'
13
+ import type { WorkflowControlInput, WorkflowStartInput } from './protocol/schema.js'
14
+
15
+ export type CommandContext = {
16
+ bridge: BridgeService
17
+ }
18
+
19
+ type CommandDefinition = {
20
+ description: string
21
+ schema: z.ZodType
22
+ execute: (input: unknown, context: CommandContext, clientId?: string) => Promise<unknown>
23
+ }
24
+
25
+ const CommandRequestSchema = z
26
+ .object({
27
+ clientId: z.uuid().optional(),
28
+ input: z.unknown(),
29
+ })
30
+ .strict()
31
+
32
+ const commandDefinitions: Record<string, CommandDefinition> = {
33
+ 'workflow.start': {
34
+ description: 'Start one Workflow in a selected connected Tapicker Extension',
35
+ schema: WorkflowStartInputSchema,
36
+ execute: async (input, context, clientId) => {
37
+ const { clientId: selectedClientId, result } = await context.bridge.sendCommandWithClientId(
38
+ 'workflow.start',
39
+ input,
40
+ clientId,
41
+ )
42
+ if (!result || typeof result !== 'object' || Array.isArray(result)) {
43
+ throw new BridgeError('InvalidExtensionResponse', 'Extension returned an invalid workflow.start response', {
44
+ status: 502,
45
+ })
46
+ }
47
+
48
+ const parsed = WorkflowStartResultSchema.safeParse({ ...result, clientId: selectedClientId })
49
+ if (!parsed.success || parsed.data.workflowId !== (input as WorkflowStartInput).workflowId) {
50
+ throw new BridgeError('InvalidExtensionResponse', 'Extension returned an invalid workflow.start response', {
51
+ status: 502,
52
+ details: parsed.success
53
+ ? {
54
+ requestedWorkflowId: (input as WorkflowStartInput).workflowId,
55
+ returnedWorkflowId: parsed.data.workflowId,
56
+ }
57
+ : parsed.error.issues,
58
+ })
59
+ }
60
+
61
+ return parsed.data
62
+ },
63
+ },
64
+ 'workflow.pause': {
65
+ description: 'Pause a running Workflow in a selected connected Tapicker Extension',
66
+ schema: WorkflowControlInputSchema,
67
+ execute: (input, context, clientId) => context.bridge.sendCommand('workflow.pause', input, clientId),
68
+ },
69
+ 'workflow.resume': {
70
+ description: 'Resume a paused Workflow in a selected connected Tapicker Extension',
71
+ schema: WorkflowControlInputSchema,
72
+ execute: (input, context, clientId) => context.bridge.sendCommand('workflow.resume', input, clientId),
73
+ },
74
+ 'workflow.stop': {
75
+ description: 'Stop a running Workflow in a selected connected Tapicker Extension',
76
+ schema: WorkflowControlInputSchema,
77
+ execute: (input, context, clientId) => context.bridge.sendCommand('workflow.stop', input, clientId),
78
+ },
79
+ 'workflow.status': {
80
+ description: 'Get the current or most recently persisted Workflow status',
81
+ schema: WorkflowControlInputSchema,
82
+ execute: async (input, context, clientId) => {
83
+ const result = await context.bridge.sendCommand('workflow.status.get', input, clientId)
84
+ const parsed = WorkflowStatusDataSchema.safeParse(result)
85
+ if (!parsed.success || parsed.data.workflowId !== (input as WorkflowControlInput).workflowId) {
86
+ throw new BridgeError('InvalidExtensionResponse', 'Extension returned an invalid workflow status response', {
87
+ status: 502,
88
+ details: parsed.success ? undefined : parsed.error.issues,
89
+ })
90
+ }
91
+ return parsed.data
92
+ },
93
+ },
94
+ 'data.get': {
95
+ description: 'Get an extracted data table by tableId; the rows count against the Extension export quota',
96
+ schema: DataInputSchema,
97
+ execute: (input, context, clientId) => context.bridge.sendCommand('data.get', input, clientId),
98
+ },
99
+ 'data.delete': {
100
+ description: 'Delete an extracted data table by tableId',
101
+ schema: DataInputSchema,
102
+ execute: (input, context, clientId) => context.bridge.sendCommand('data.delete', input, clientId),
103
+ },
104
+ }
105
+
106
+ export function getCommandDefinition(name: string) {
107
+ return commandDefinitions[name]
108
+ }
109
+
110
+ export async function dispatchCommand(name: string, rawInput: unknown, context: CommandContext) {
111
+ const definition = getCommandDefinition(name)
112
+ if (!definition) {
113
+ throw new BridgeError('CommandNotFound', `Unsupported command: ${name}`, { status: 404 })
114
+ }
115
+
116
+ const request = CommandRequestSchema.safeParse(rawInput)
117
+ if (!request.success) {
118
+ throw new BridgeError('InvalidArguments', `Invalid command request for ${name}`, {
119
+ status: 400,
120
+ details: request.error.issues,
121
+ })
122
+ }
123
+
124
+ const parsed = definition.schema.safeParse(request.data.input)
125
+ if (!parsed.success) {
126
+ throw new BridgeError('InvalidArguments', `Invalid arguments for ${name}`, {
127
+ status: 400,
128
+ details: parsed.error.issues,
129
+ })
130
+ }
131
+
132
+ return definition.execute(parsed.data, context, request.data.clientId)
133
+ }
134
+
135
+ export function getCommandCapabilities() {
136
+ return [
137
+ ...Object.entries(commandDefinitions).map(([name, definition]) => ({
138
+ name,
139
+ description: definition.description,
140
+ inputSchema: z.toJSONSchema(
141
+ z
142
+ .object({
143
+ clientId: z.uuid().optional(),
144
+ input: definition.schema,
145
+ })
146
+ .strict(),
147
+ { io: 'input' },
148
+ ),
149
+ })),
150
+ {
151
+ name: 'workflow.watch',
152
+ description: 'Watch one Workflow through the workflow.watch WebSocket endpoint',
153
+ transport: 'websocket',
154
+ path: DEFAULT_WORKFLOW_WATCH_WS_PATH,
155
+ inputSchema: z.toJSONSchema(WorkflowWatchRequestSchema, { io: 'input' }),
156
+ },
157
+ ]
158
+ }
@@ -0,0 +1,10 @@
1
+ export const VERSION = '1.0.0'
2
+ export const PROTOCOL_VERSION = 1
3
+
4
+ export const DEFAULT_HOST = '127.0.0.1'
5
+ export const DEFAULT_PORT = 9520
6
+ export const DEFAULT_PORT_MAX = 9530
7
+ export const DEFAULT_WS_PATH = '/ws'
8
+ export const DEFAULT_WORKFLOW_WATCH_WS_PATH = '/v1/commands/workflow.watch'
9
+ export const DEFAULT_MCP_PATH = '/mcp'
10
+ export const DEFAULT_REQUEST_TIMEOUT = 30_000
package/src/errors.ts ADDED
@@ -0,0 +1,72 @@
1
+ export type BridgeErrorOptions = {
2
+ status?: number
3
+ details?: unknown
4
+ cause?: unknown
5
+ }
6
+
7
+ export type SerializedBridgeError = {
8
+ code: string
9
+ message: string
10
+ details?: unknown
11
+ }
12
+
13
+ export class BridgeError extends Error {
14
+ readonly code: string
15
+ readonly status: number
16
+ readonly details: unknown
17
+
18
+ constructor(code: string, message: string, options: BridgeErrorOptions = {}) {
19
+ super(message, { cause: options.cause })
20
+ this.name = 'BridgeError'
21
+ this.code = code
22
+ this.status = options.status ?? 400
23
+ this.details = options.details
24
+ }
25
+ }
26
+
27
+ export function toBridgeError(error: unknown, fallbackCode = 'InternalError', fallbackStatus = 500) {
28
+ if (error instanceof BridgeError) {
29
+ return error
30
+ }
31
+
32
+ if (error instanceof Error) {
33
+ return new BridgeError(fallbackCode, error.message, {
34
+ status: fallbackStatus,
35
+ cause: error,
36
+ })
37
+ }
38
+
39
+ return new BridgeError(fallbackCode, 'An unexpected error occurred', {
40
+ status: fallbackStatus,
41
+ details: error,
42
+ cause: error,
43
+ })
44
+ }
45
+
46
+ export function serializeBridgeError(error: unknown): SerializedBridgeError {
47
+ const bridgeError = toBridgeError(error)
48
+ const result: SerializedBridgeError = {
49
+ code: bridgeError.code,
50
+ message: bridgeError.message,
51
+ }
52
+
53
+ if (bridgeError.details !== undefined) {
54
+ result.details = bridgeError.details
55
+ }
56
+
57
+ return result
58
+ }
59
+
60
+ export function safeError(error: unknown) {
61
+ return {
62
+ ok: false as const,
63
+ error: serializeBridgeError(error),
64
+ }
65
+ }
66
+
67
+ export function safeSuccess<T>(data: T) {
68
+ return {
69
+ ok: true as const,
70
+ data,
71
+ }
72
+ }
package/src/index.ts ADDED
@@ -0,0 +1,35 @@
1
+ export { BridgeServer } from './server.js'
2
+ export { BridgeService } from './bridge/bridge-service.js'
3
+ export { ExtensionSession } from './bridge/extension-session.js'
4
+ export { dispatchCommand, getCommandCapabilities, getCommandDefinition } from './commands.js'
5
+ export { BridgeError, safeError, safeSuccess, serializeBridgeError } from './errors.js'
6
+ export {
7
+ BridgeHelloDataSchema,
8
+ BridgePairDataSchema,
9
+ DataInputSchema,
10
+ ExtensionHelloInputSchema,
11
+ ExtensionPairInputSchema,
12
+ MessageEnvelopeSchema,
13
+ SafeResultSchema,
14
+ TabTargetSchema,
15
+ WorkflowControlInputSchema,
16
+ WorkflowLifecycleStateSchema,
17
+ WorkflowStatusEventSchema,
18
+ WorkflowStatusDataSchema,
19
+ WorkflowStartInputSchema,
20
+ WorkflowStartResultSchema,
21
+ WorkflowWatchRequestSchema,
22
+ createEnvelope,
23
+ parseEnvelope,
24
+ } from './protocol/schema.js'
25
+ export type {
26
+ DataInput,
27
+ WorkflowControlInput,
28
+ WorkflowStartInput,
29
+ WorkflowStartResult,
30
+ WorkflowStatusData,
31
+ WorkflowStatusEvent,
32
+ WorkflowWatchRequest,
33
+ } from './protocol/schema.js'
34
+ export { connectWorkflowWatch, waitForWorkflowStatus, workflowWatchWebSocketUrl } from './workflow-watch.js'
35
+ export type { WorkflowWatchConnection, WorkflowWatchInput, WorkflowWatchResult } from './workflow-watch.js'
package/src/mcp.ts ADDED
@@ -0,0 +1,386 @@
1
+ import { McpServer } from '@modelcontextprotocol/server'
2
+ import { z } from 'zod'
3
+ import { VERSION } from './constants.js'
4
+ import { dispatchCommand } from './commands.js'
5
+ import { BridgeError, safeError, safeSuccess } from './errors.js'
6
+ import type { BridgeService } from './bridge/bridge-service.js'
7
+ import { TabTargetSchema, WorkflowStatusEventSchema } from './protocol/schema.js'
8
+ import type { WorkflowStatusEvent } from './protocol/schema.js'
9
+ import { connectWorkflowWatch, waitForWorkflowStatus, workflowWatchWebSocketUrl } from './workflow-watch.js'
10
+ import type { WorkflowWatchInput, WorkflowWatchResult } from './workflow-watch.js'
11
+
12
+ export type TapickerMcpApi = {
13
+ executeCommand: (name: string, request: unknown) => Promise<unknown>
14
+ listExtensions: () => Promise<unknown[]>
15
+ watchWorkflow: (
16
+ input: WorkflowWatchInput,
17
+ onStatus: (event: WorkflowStatusEvent) => void,
18
+ signal?: AbortSignal,
19
+ ) => Promise<WorkflowWatchResult>
20
+ }
21
+
22
+ export function createBridgeMcpApi(bridge: BridgeService): TapickerMcpApi {
23
+ return {
24
+ executeCommand: (name, request) => dispatchCommand(name, request, { bridge }),
25
+ listExtensions: async () => bridge.getExtensionStatuses(),
26
+ watchWorkflow: (input, onStatus, signal) => {
27
+ const clientId = bridge.resolveCommandClientId('workflow.status', input.clientId)
28
+ return waitForWorkflowStatus({
29
+ clientId,
30
+ workflowId: input.workflowId,
31
+ subscribe: (handleStatus, _handleClose) => {
32
+ const listener = (event: WorkflowStatusEvent) => {
33
+ if (event.clientId === clientId && event.workflowId === input.workflowId) handleStatus(event)
34
+ }
35
+ bridge.on('workflow.status', listener)
36
+ return () => bridge.off('workflow.status', listener)
37
+ },
38
+ readStatus: async () => {
39
+ const status = await bridge.sendCommand<unknown>(
40
+ 'workflow.status.get',
41
+ { workflowId: input.workflowId },
42
+ clientId,
43
+ )
44
+ return parseWorkflowStatusEvent(status, clientId, input.workflowId)
45
+ },
46
+ onStatus,
47
+ timeoutMs: input.timeoutMs,
48
+ signal,
49
+ })
50
+ },
51
+ }
52
+ }
53
+
54
+ export function createHttpMcpApi(baseUrl: string, token?: string): TapickerMcpApi {
55
+ const bridgeUrl = parseBridgeUrl(baseUrl)
56
+ const headers: Record<string, string> = {}
57
+ if (token) {
58
+ headers.Authorization = `Bearer ${token}`
59
+ }
60
+
61
+ const executeCommand = async (name: string, request: unknown) => {
62
+ const response = await fetchBridge(new URL(`/v1/commands/${encodeURIComponent(name)}`, bridgeUrl), {
63
+ method: 'POST',
64
+ headers: { ...headers, 'Content-Type': 'application/json' },
65
+ body: JSON.stringify(request),
66
+ })
67
+ const body = await readBridgeResponse(response)
68
+
69
+ if (!response.ok || !body.ok) {
70
+ throw new BridgeError(body.error?.code ?? 'BridgeRequestFailed', body.error?.message ?? response.statusText, {
71
+ status: response.status,
72
+ details: body.error?.details,
73
+ })
74
+ }
75
+
76
+ return body.data
77
+ }
78
+
79
+ return {
80
+ executeCommand,
81
+ listExtensions: async () => {
82
+ const response = await fetchBridge(new URL('/v1/extensions', bridgeUrl), { headers })
83
+ const body = await readBridgeResponse(response)
84
+
85
+ if (!response.ok || !Array.isArray(body.extensions)) {
86
+ throw new BridgeError(body.error?.code ?? 'BridgeRequestFailed', body.error?.message ?? response.statusText, {
87
+ status: response.status,
88
+ details: body.error?.details,
89
+ })
90
+ }
91
+
92
+ return body.extensions
93
+ },
94
+ watchWorkflow: async (input, onStatus, signal) => {
95
+ const connection = await connectWorkflowWatch(
96
+ workflowWatchWebSocketUrl(bridgeUrl),
97
+ { clientId: input.clientId, workflowId: input.workflowId },
98
+ token,
99
+ signal,
100
+ )
101
+ try {
102
+ return await waitForWorkflowStatus({
103
+ clientId: connection.clientId,
104
+ workflowId: input.workflowId,
105
+ subscribe: (handleStatus, handleClose) => {
106
+ const removeStatusListener = connection.onStatus(handleStatus)
107
+ const removeCloseListener = connection.onClose(handleClose)
108
+ return () => {
109
+ removeStatusListener()
110
+ removeCloseListener()
111
+ }
112
+ },
113
+ readStatus: async () => {
114
+ const status = await executeCommand('workflow.status', {
115
+ clientId: connection.clientId,
116
+ input: { workflowId: input.workflowId },
117
+ })
118
+ return parseWorkflowStatusEvent(status, connection.clientId, input.workflowId)
119
+ },
120
+ onStatus,
121
+ timeoutMs: input.timeoutMs,
122
+ signal,
123
+ })
124
+ } finally {
125
+ connection.close()
126
+ }
127
+ },
128
+ }
129
+ }
130
+
131
+ export function createTapickerMcpServer(api: TapickerMcpApi) {
132
+ const server = new McpServer({ name: 'tapicker-bridge', version: VERSION })
133
+
134
+ server.registerTool(
135
+ 'extensions_list',
136
+ {
137
+ title: 'List connected Extensions',
138
+ description: 'List connected Tapicker Extensions and their clientId values for targeting Workflow commands.',
139
+ inputSchema: z.object({}).strict(),
140
+ annotations: { readOnlyHint: true },
141
+ },
142
+ async () => runTool(() => api.listExtensions()),
143
+ )
144
+
145
+ server.registerTool(
146
+ 'workflow_start',
147
+ {
148
+ title: 'Start Workflow',
149
+ description:
150
+ 'Start a Workflow in a connected Tapicker Extension. Returns the workflowId, full workflowName, and target clientId. If multiple Extensions are connected, provide clientId.',
151
+ inputSchema: z
152
+ .object({
153
+ workflowId: z.string().min(1).describe('The Workflow ID to start.'),
154
+ args: z.record(z.string(), z.unknown()).optional().describe('Optional Workflow input arguments.'),
155
+ tab: TabTargetSchema.optional().describe('Browser tab target; defaults to the active tab.'),
156
+ clientId: z
157
+ .uuid()
158
+ .optional()
159
+ .describe('Target Extension UUID; required when multiple Extensions are connected.'),
160
+ })
161
+ .strict(),
162
+ annotations: { destructiveHint: true },
163
+ },
164
+ async ({ clientId, ...input }) => runTool(() => api.executeCommand('workflow.start', { clientId, input })),
165
+ )
166
+
167
+ for (const action of ['pause', 'resume', 'stop'] as const) {
168
+ server.registerTool(
169
+ `workflow_${action}`,
170
+ {
171
+ title: `${capitalize(action)} Workflow`,
172
+ description: `${capitalize(action)} a Workflow by workflowId. If multiple Extensions are connected, provide clientId.`,
173
+ inputSchema: z
174
+ .object({
175
+ workflowId: z.string().min(1).describe('The Workflow ID to control.'),
176
+ clientId: z
177
+ .uuid()
178
+ .optional()
179
+ .describe('Target Extension UUID; required when multiple Extensions are connected.'),
180
+ })
181
+ .strict(),
182
+ annotations: action === 'stop' ? { destructiveHint: true } : undefined,
183
+ },
184
+ async ({ clientId, ...input }) => runTool(() => api.executeCommand(`workflow.${action}`, { clientId, input })),
185
+ )
186
+ }
187
+
188
+ server.registerTool(
189
+ 'workflow_status',
190
+ {
191
+ title: 'Get Workflow status',
192
+ description:
193
+ 'Get the current or most recently persisted status for a Workflow. If multiple Extensions are connected, provide clientId.',
194
+ inputSchema: z
195
+ .object({
196
+ workflowId: z.string().min(1).describe('The Workflow ID to query.'),
197
+ clientId: z
198
+ .uuid()
199
+ .optional()
200
+ .describe('Target Extension UUID; required when multiple Extensions are connected.'),
201
+ })
202
+ .strict(),
203
+ annotations: { readOnlyHint: true },
204
+ },
205
+ async ({ clientId, ...input }) => runTool(() => api.executeCommand('workflow.status', { clientId, input })),
206
+ )
207
+
208
+ server.registerTool(
209
+ 'data_get',
210
+ {
211
+ title: 'Get extracted data',
212
+ description:
213
+ 'Get all rows from an extracted data table by tableId. This consumes the export row quota of the connected Extension account. If multiple Extensions are connected, provide clientId.',
214
+ inputSchema: z
215
+ .object({
216
+ tableId: z.string().trim().min(1).max(128).describe('The data table ID copied from Tapicker Data.'),
217
+ clientId: z
218
+ .uuid()
219
+ .optional()
220
+ .describe('Target Extension UUID; required when multiple Extensions are connected.'),
221
+ })
222
+ .strict(),
223
+ },
224
+ async ({ clientId, tableId }) => runTool(() => api.executeCommand('data.get', { clientId, input: { tableId } })),
225
+ )
226
+
227
+ server.registerTool(
228
+ 'data_delete',
229
+ {
230
+ title: 'Delete extracted data',
231
+ description:
232
+ 'Delete an extracted data table and remove it from Tapicker Data by tableId. If multiple Extensions are connected, provide clientId.',
233
+ inputSchema: z
234
+ .object({
235
+ tableId: z.string().trim().min(1).max(128).describe('The data table ID copied from Tapicker Data.'),
236
+ clientId: z
237
+ .uuid()
238
+ .optional()
239
+ .describe('Target Extension UUID; required when multiple Extensions are connected.'),
240
+ })
241
+ .strict(),
242
+ annotations: { destructiveHint: true },
243
+ },
244
+ async ({ clientId, tableId }) => runTool(() => api.executeCommand('data.delete', { clientId, input: { tableId } })),
245
+ )
246
+
247
+ server.registerTool(
248
+ 'workflow_watch',
249
+ {
250
+ title: 'Wait for Workflow completion',
251
+ description:
252
+ 'Listen for Workflow status changes until it completes, stops, or fails. Returns the latest status when timeoutMs is reached. If multiple Extensions are connected, provide clientId.',
253
+ inputSchema: z
254
+ .object({
255
+ workflowId: z.string().min(1).describe('The Workflow ID to watch.'),
256
+ clientId: z
257
+ .uuid()
258
+ .optional()
259
+ .describe('Target Extension UUID; required when multiple Extensions are connected.'),
260
+ timeoutMs: z
261
+ .number()
262
+ .int()
263
+ .positive()
264
+ .max(2_147_483_647)
265
+ .optional()
266
+ .describe('Optional maximum time to wait in milliseconds.'),
267
+ })
268
+ .strict(),
269
+ annotations: { readOnlyHint: true },
270
+ },
271
+ async ({ clientId, workflowId, timeoutMs }, context) => {
272
+ const progressToken = context.mcpReq._meta?.progressToken
273
+ let progress = 0
274
+ let notificationQueue = Promise.resolve()
275
+ return runTool(async () => {
276
+ const result = await api.watchWorkflow(
277
+ { clientId, workflowId, timeoutMs },
278
+ (event) => {
279
+ if (progressToken === undefined) return
280
+ progress += 1
281
+ notificationQueue = notificationQueue
282
+ .then(() =>
283
+ context.mcpReq.notify({
284
+ method: 'notifications/progress',
285
+ params: {
286
+ progressToken,
287
+ progress,
288
+ message: `Workflow status: ${event.state}`,
289
+ },
290
+ }),
291
+ )
292
+ .catch(() => undefined)
293
+ },
294
+ context.mcpReq.signal,
295
+ )
296
+ await notificationQueue
297
+ return result
298
+ })
299
+ },
300
+ )
301
+
302
+ return server
303
+ }
304
+
305
+ function parseWorkflowStatusEvent(value: unknown, clientId: string, workflowId: string) {
306
+ const parsed = WorkflowStatusEventSchema.safeParse({ ...(value as object), clientId })
307
+ if (!parsed.success || parsed.data.workflowId !== workflowId) {
308
+ throw new BridgeError('InvalidExtensionResponse', 'Extension returned an invalid workflow status response', {
309
+ status: 502,
310
+ details: parsed.success ? undefined : parsed.error.issues,
311
+ })
312
+ }
313
+ return parsed.data
314
+ }
315
+
316
+ function capitalize(value: string) {
317
+ return `${value[0].toUpperCase()}${value.slice(1)}`
318
+ }
319
+
320
+ function parseBridgeUrl(value: string) {
321
+ let url: URL
322
+ try {
323
+ url = new URL(value)
324
+ } catch (cause) {
325
+ throw new BridgeError('InvalidBridgeUrl', 'Bridge URL must be a valid HTTP or HTTPS URL', { status: 400, cause })
326
+ }
327
+
328
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
329
+ throw new BridgeError('InvalidBridgeUrl', 'Bridge URL must use HTTP or HTTPS', { status: 400 })
330
+ }
331
+ if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
332
+ throw new BridgeError('InvalidBridgeUrl', 'Bridge URL must contain only the HTTP(S) origin and port', {
333
+ status: 400,
334
+ })
335
+ }
336
+
337
+ return url
338
+ }
339
+
340
+ async function fetchBridge(url: URL, init: RequestInit) {
341
+ try {
342
+ return await fetch(url, { ...init, signal: AbortSignal.timeout(30_000) })
343
+ } catch (cause) {
344
+ throw new BridgeError('BridgeUnavailable', `Unable to reach Tapicker Bridge at ${url.origin}`, {
345
+ status: 503,
346
+ cause,
347
+ })
348
+ }
349
+ }
350
+
351
+ async function readBridgeResponse(response: Response) {
352
+ let body: {
353
+ ok?: boolean
354
+ data?: unknown
355
+ error?: { code?: string; message?: string; details?: unknown }
356
+ extensions?: unknown[]
357
+ }
358
+
359
+ try {
360
+ body = (await response.json()) as typeof body
361
+ } catch (cause) {
362
+ throw new BridgeError('InvalidBridgeResponse', 'Bridge returned an invalid JSON response', {
363
+ status: 502,
364
+ cause,
365
+ })
366
+ }
367
+
368
+ if (!body || typeof body !== 'object') {
369
+ throw new BridgeError('InvalidBridgeResponse', 'Bridge returned an invalid response', { status: 502 })
370
+ }
371
+
372
+ return body
373
+ }
374
+
375
+ async function runTool(operation: () => Promise<unknown>) {
376
+ try {
377
+ return {
378
+ content: [{ type: 'text' as const, text: JSON.stringify(safeSuccess(await operation()), null, 2) }],
379
+ }
380
+ } catch (error) {
381
+ return {
382
+ content: [{ type: 'text' as const, text: JSON.stringify(safeError(error), null, 2) }],
383
+ isError: true,
384
+ }
385
+ }
386
+ }