Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions apps/mcp/guest/studio-app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* Tidebase Studio guest — a real MCP-App. Runs inside the host's sandboxed
* iframe, connects to the host over the MCP-Apps protocol (ui/initialize
* handshake via PostMessageTransport), and calls server tools through the host
* (which routes them through governance). Bundled self-contained by esbuild and
* inlined into the ui:// resource — no CDN, no external deps at runtime.
*/
import { App } from '@modelcontextprotocol/ext-apps/app-with-deps'

type Run = { id: string; status?: string; workflowName?: string; workflow_name?: string }

const STYLES = `
:root { color-scheme: light dark; }
body { font: 13px/1.5 ui-sans-serif, system-ui, sans-serif; margin: 0; padding: 12px; }
h1 { font-size: 14px; margin: 0 0 8px; }
.row { display: flex; gap: 8px; align-items: center; padding: 6px 8px; border: 1px solid color-mix(in srgb, currentColor 14%, transparent); border-radius: 8px; margin-bottom: 6px; }
.muted { opacity: 0.6; }
.status { font-weight: 600; }
.status.running { color: #2563eb; } .status.completed { color: #16a34a; }
.status.failed { color: #dc2626; } .status.cancelled { color: #d97706; }
.err { color: #dc2626; white-space: pre-wrap; }
`

function el(tag: string, cls?: string, text?: string) {
const n = document.createElement(tag)
if (cls) n.className = cls
if (text != null) n.textContent = text
return n
}

async function callJson(app: App, name: string, args: Record<string, unknown>): Promise<any> {
const res = await app.callServerTool({ name, arguments: args })
const blocks = (res.content as Array<{ type?: string; text?: string }> | undefined) ?? []
const text = blocks.find((b) => typeof b.text === 'string')?.text ?? '{}'
try {
return JSON.parse(text)
} catch {
return text
}
}

async function loadRuns(app: App) {
const statusEl = document.getElementById('status')!
const runsEl = document.getElementById('runs')!
try {
const data = await callJson(app, 'tidebase_list_runs', { limit: 20 })
const runs: Run[] = (data && data.runs) || []
document.getElementById('count')!.textContent = '(' + runs.length + ')'
statusEl.style.display = 'none'
runsEl.innerHTML = ''
if (!runs.length) runsEl.appendChild(el('div', 'muted', 'No runs yet.'))
for (const run of runs) {
const row = el('div', 'row')
row.appendChild(el('span', 'status ' + (run.status || ''), run.status || '?'))
row.appendChild(el('span', undefined, run.workflowName || run.workflow_name || run.id))
row.appendChild(el('span', 'muted', String(run.id).slice(0, 18)))
runsEl.appendChild(row)
}
} catch (err: any) {
statusEl.className = 'err'
statusEl.textContent = 'Failed to load runs: ' + (err?.message || err)
}
}

async function main() {
document.body.innerHTML =
`<style>${STYLES}</style>` +
`<h1>Tidebase Studio <span id="count" class="muted"></span></h1>` +
`<div id="status" class="muted">Connecting…</div>` +
`<div id="runs"></div>`

const app = new App({ name: 'tidebase-studio', version: '0.1.0' }, {})
await app.connect() // ui/initialize handshake over PostMessageTransport
document.getElementById('status')!.textContent = 'Loading runs…'
await loadRuns(app)
}

main().catch((err: any) => {
document.body.innerHTML = `<pre class="err">Studio crashed: ${err?.message || err}</pre>`
})
23 changes: 23 additions & 0 deletions apps/mcp/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "@tidebase/mcp-server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "node scripts/build-guest.mjs && tsc -p tsconfig.json",
"start": "node dist/index.js",
"build:guest": "node scripts/build-guest.mjs"
},
"dependencies": {
"@hono/node-server": "^1.13.8",
"@mcp-ui/server": "^6.1.0",
"hono": "^4.6.16",
"@modelcontextprotocol/ext-apps": "^1.7.4"
},
"devDependencies": {
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"esbuild": "^0.24.0"
}
}
28 changes: 28 additions & 0 deletions apps/mcp/scripts/build-guest.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Bundle the Studio guest MCP-App into a single self-contained IIFE and emit it
// as a TS string constant that ui.ts inlines into the ui:// resource HTML.
import { build } from 'esbuild'
import { writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')

const result = await build({
entryPoints: [resolve(root, 'guest/studio-app.ts')],
bundle: true,
format: 'iife',
platform: 'browser',
target: 'es2020',
minify: true,
write: false,
legalComments: 'none'
})

const code = result.outputFiles[0].text
const out = resolve(root, 'src/guestBundle.generated.ts')
writeFileSync(
out,
'// AUTO-GENERATED by scripts/build-guest.mjs — do not edit.\n' +
`export const GUEST_BUNDLE: string = ${JSON.stringify(code)}\n`
)
console.log(`guest bundle: ${(code.length / 1024).toFixed(1)} KiB -> src/guestBundle.generated.ts`)
2 changes: 2 additions & 0 deletions apps/mcp/src/guestBundle.generated.ts

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions apps/mcp/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { handleMcp } from './server.js'

/**
* Tidebase product MCP server (standalone). Exposes the control-plane tools +
* the ui:// Studio app over the same plain-JSON-RPC subset Aura's capability
* client speaks. Reaches the Tidebase server via TIDEBASE_URL / TIDEBASE_API_KEY.
*/
const port = Number(process.env.MCP_PORT ?? 7377)
const app = new Hono()
app.use('*', cors())
app.get('/health', (c) => c.json({ ok: true, server: 'tidebase-mcp' }))

app.post('/', async (c) => {
const body = await c.req.json().catch(() => null)
const response = await handleMcp(body)
if (response === null) return c.body(null, 202) // notification(s) only
return c.json(response)
})

serve({ fetch: app.fetch, port })
console.log(`Tidebase MCP server listening on http://localhost:${port} (target: ${process.env.TIDEBASE_URL ?? 'http://localhost:7373'})`)
85 changes: 85 additions & 0 deletions apps/mcp/src/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { STUDIO_URI, studioResource } from './ui.js'
import { TOOL_DEFS, callTool } from './tools.js'

/**
* Minimal MCP-over-plain-JSON-RPC handler (the same subset Aura's capability
* client speaks): initialize, ping, tools/list, tools/call, plus resources/list
* and resources/read so a host can fetch the ui:// Studio app.
*/
const MCP_PROTOCOL_VERSION = '2025-06-18'

type JsonRpcRequest = { jsonrpc?: string; id?: string | number | null; method?: string; params?: Record<string, unknown> }
type JsonRpcResponse = { jsonrpc: '2.0'; id: string | number | null; result?: unknown; error?: { code: number; message: string } }

function result(id: string | number | null, value: unknown): JsonRpcResponse {
return { jsonrpc: '2.0', id, result: value }
}
function error(id: string | number | null, code: number, message: string): JsonRpcResponse {
return { jsonrpc: '2.0', id, error: { code, message } }
}
function toolText(payload: unknown, isError = false) {
return { content: [{ type: 'text', text: typeof payload === 'string' ? payload : JSON.stringify(payload) }], isError }
}

export async function handleMcp(body: unknown): Promise<JsonRpcResponse | JsonRpcResponse[] | null> {
if (Array.isArray(body)) {
const out: JsonRpcResponse[] = []
for (const entry of body) {
const r = await handleSingle(entry as JsonRpcRequest)
if (r) out.push(r)
}
return out.length ? out : null
}
return handleSingle(body as JsonRpcRequest)
}

async function handleSingle(req: JsonRpcRequest): Promise<JsonRpcResponse | null> {
if (!req || typeof req !== 'object' || typeof req.method !== 'string') {
return error(null, -32600, 'invalid JSON-RPC request')
}
const id = req.id ?? null
const isNotification = req.id === undefined
const params = req.params ?? {}

switch (req.method) {
case 'initialize':
return result(id, {
protocolVersion: typeof params.protocolVersion === 'string' ? params.protocolVersion : MCP_PROTOCOL_VERSION,
capabilities: { tools: { listChanged: false }, resources: { listChanged: false } },
serverInfo: { name: 'tidebase-studio', version: '0.1.0' }
})
case 'notifications/initialized':
case 'notifications/cancelled':
return null
case 'ping':
return result(id, {})
case 'tools/list':
return result(id, { tools: TOOL_DEFS })
case 'tools/call': {
const name = typeof params.name === 'string' ? params.name : ''
const args = (params.arguments ?? {}) as Record<string, unknown>
try {
return result(id, toolText(await callTool(name, args)))
} catch (e) {
// Tool-level failure → tool result the caller can read (MCP convention).
return result(id, toolText({ error: e instanceof Error ? e.message : String(e) }, true))
}
}
case 'resources/list':
return result(id, {
resources: [
{ uri: STUDIO_URI, name: 'Tidebase Studio', description: 'Runs, grants, approvals', mimeType: 'text/html' }
]
})
case 'resources/read': {
const uri = typeof params.uri === 'string' ? params.uri : ''
if (uri !== STUDIO_URI) return error(id, -32602, `unknown resource ${uri}`)
// studioResource() returns an MCP embedded-resource block { type:'resource', resource:{...} }.
const res = studioResource() as { resource: unknown }
return result(id, { contents: [res.resource] })
}
default:
if (isNotification) return null
return error(id, -32601, `method not found: ${req.method}`)
}
}
104 changes: 104 additions & 0 deletions apps/mcp/src/tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Tidebase product MCP tools — a thin adapter over the Tidebase REST API. The
* server authenticates with the admin key (TIDEBASE_API_KEY); these are the
* read/operate surface that a host (e.g. Aura) renders as a control-plane app.
*/
const TIDEBASE_URL = (process.env.TIDEBASE_URL ?? 'http://localhost:7373').replace(/\/$/, '')
const TIDEBASE_API_KEY = process.env.TIDEBASE_API_KEY

async function api(path: string, init?: { method?: string; body?: unknown }): Promise<unknown> {
const headers: Record<string, string> = { 'content-type': 'application/json' }
if (TIDEBASE_API_KEY) headers.authorization = `Bearer ${TIDEBASE_API_KEY}`
const res = await fetch(`${TIDEBASE_URL}${path}`, {
method: init?.method ?? 'GET',
headers,
body: init?.body === undefined ? undefined : JSON.stringify(init.body),
signal: AbortSignal.timeout(15_000)
})
const text = await res.text()
const json = text ? safeJson(text) : null
if (!res.ok) throw new Error(`Tidebase ${path} -> ${res.status}: ${typeof json === 'object' ? JSON.stringify(json) : text}`)
return json
}

function safeJson(text: string): unknown {
try {
return JSON.parse(text)
} catch {
return text
}
}

export type ToolDef = {
name: string
description: string
inputSchema: Record<string, unknown>
}

export const TOOL_DEFS: ToolDef[] = [
{
name: 'tidebase_list_runs',
description: 'List recent agent runs (id, workflow, status, timestamps).',
inputSchema: { type: 'object', properties: { limit: { type: 'number' } } }
},
{
name: 'tidebase_get_run',
description: 'Get a single run with its steps, gates, grants and event timeline.',
inputSchema: { type: 'object', properties: { runId: { type: 'string' } }, required: ['runId'] }
},
{
name: 'tidebase_audit',
description: 'Read grant.* audit receipts (authorization ledger), optionally scoped to a run.',
inputSchema: { type: 'object', properties: { runId: { type: 'string' }, limit: { type: 'number' } } }
},
{
name: 'tidebase_approve_gate',
description: 'Resolve a pending approval gate (approve or reject) using its resolve token.',
inputSchema: {
type: 'object',
properties: {
runId: { type: 'string' },
gateId: { type: 'string' },
token: { type: 'string' },
decision: { type: 'string', enum: ['approved', 'rejected'] }
},
required: ['runId', 'gateId', 'token']
}
}
]

export async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
switch (name) {
case 'tidebase_list_runs': {
const data = (await api('/runs')) as { runs?: unknown[] } | unknown[]
const runs = Array.isArray(data) ? data : (data?.runs ?? [])
const limit = typeof args.limit === 'number' ? args.limit : 20
return { runs: (runs as unknown[]).slice(0, limit) }
}
case 'tidebase_get_run': {
if (typeof args.runId !== 'string') throw new Error('runId is required')
return api(`/runs/${encodeURIComponent(args.runId)}`)
}
case 'tidebase_audit': {
const qs = new URLSearchParams()
if (typeof args.runId === 'string') qs.set('runId', args.runId)
qs.set('limit', String(typeof args.limit === 'number' ? args.limit : 50))
return { receipts: await api(`/audit?${qs.toString()}`) }
}
case 'tidebase_approve_gate': {
if (typeof args.runId !== 'string' || typeof args.gateId !== 'string' || typeof args.token !== 'string') {
throw new Error('runId, gateId and token are required')
}
return api(`/runs/${encodeURIComponent(args.runId)}/gates/${encodeURIComponent(args.gateId)}/resolve`, {
method: 'POST',
body: {
token: args.token,
decision: args.decision === 'rejected' ? 'rejected' : 'approved',
actor: 'tidebase-studio'
}
})
}
default:
throw new Error(`unknown tool ${name}`)
}
}
21 changes: 21 additions & 0 deletions apps/mcp/src/ui.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createUIResource } from '@mcp-ui/server'
import { GUEST_BUNDLE } from './guestBundle.generated.js'

export const STUDIO_URI = 'ui://tidebase/studio'

/**
* The Tidebase Studio ui:// resource. The HTML is a minimal shell hosting the
* bundled guest MCP-App (guest/studio-app.ts → esbuild IIFE). At runtime the
* guest speaks the MCP-Apps protocol to the host over postMessage; it does not
* fetch anything itself, so a strict sandbox CSP is fine.
*/
const SAFE_BUNDLE = GUEST_BUNDLE.replace(/<\/script>/gi, '<\\/script>')
const STUDIO_HTML = `<!doctype html><html><head><meta charset="utf-8" /></head><body><script>${SAFE_BUNDLE}</script></body></html>`

export function studioResource() {
return createUIResource({
uri: STUDIO_URI,
content: { type: 'rawHtml', htmlString: STUDIO_HTML },
encoding: 'text'
})
}
13 changes: 13 additions & 0 deletions apps/mcp/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
Loading
Loading