Migrate strict Effect runtime surfaces

This commit is contained in:
elpresidank 2026-06-02 00:22:04 -05:00
parent f6878d4dd7
commit b4ee2b691f
35 changed files with 1717 additions and 1410 deletions

View file

@ -1,14 +1,21 @@
import {OpenAiClient, OpenAiLanguageModel} from "@effect/ai-openai";
import {BunHttpServer, BunRuntime} from "@effect/platform-bun";
import {createTrustGraphSocket, type BaseApi, type Term as ClientTerm} from "@trustgraph/client";
import {Context, Effect, Layer, Redacted} from "effect";
import {Config, Context, Effect, Layer, Redacted} from "effect";
import * as O from "effect/Option";
import * as Predicate from "effect/Predicate";
import {LanguageModel, McpServer, Prompt, Tool, Toolkit} from "effect/unstable/ai";
import {FetchHttpClient, HttpRouter} from "effect/unstable/http";
import {HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi} from "effect/unstable/httpapi";
import * as S from "effect/Schema";
const annotateTool = <T extends Tool.Any>(
tool: T,
const annotateTool = <Name extends string, Config extends {
readonly parameters: S.Top
readonly success: S.Top
readonly failure: S.Top
readonly failureMode: Tool.FailureMode
}, Requirements>(
tool: Tool.Tool<Name, Config, Requirements>,
annotations: {
readonly title: string
readonly readOnly: boolean
@ -17,14 +24,14 @@ const annotateTool = <T extends Tool.Any>(
readonly openWorld: boolean
readonly strict?: boolean
},
): T =>
): Tool.Tool<Name, Config, Requirements> =>
tool
.annotate(Tool.Title, annotations.title)
.annotate(Tool.Readonly, annotations.readOnly)
.annotate(Tool.Destructive, annotations.destructive)
.annotate(Tool.Idempotent, annotations.idempotent)
.annotate(Tool.OpenWorld, annotations.openWorld)
.annotate(Tool.Strict, annotations.strict ?? true) as T
.annotate(Tool.Strict, annotations.strict ?? true)
class PromptSummary extends S.Class<PromptSummary>("PromptSummary")(
{
@ -1217,18 +1224,14 @@ export interface TrustGraphMcpConfigShape {
readonly version: string
readonly mcpPath: HttpRouter.PathInput
readonly openAiModel: string
readonly openAiApiKey: string | undefined
readonly openAiApiKey: Redacted.Redacted | undefined
readonly port: number
}
const readNonEmpty = (value: string | undefined): string | undefined =>
value !== undefined && value.length > 0 ? value : undefined
const resolvePort = (value: number | undefined): number => {
if (value !== undefined) {
return value
}
const raw = readNonEmpty(process.env.PORT)
const parsePort = (raw: string | undefined): number => {
if (raw === undefined) {
return 3000
}
@ -1236,28 +1239,46 @@ const resolvePort = (value: number | undefined): number => {
return Number.isFinite(parsed) ? parsed : 3000
}
export const loadTrustGraphMcpConfig = Effect.fn("loadTrustGraphMcpConfig")(function*(
options: TrustGraphMcpOptions = {},
) {
const gatewayUrl = O.getOrUndefined(yield* Config.string("GATEWAY_URL").pipe(Config.option))
const user = O.getOrUndefined(yield* Config.string("USER_ID").pipe(Config.option))
const gatewaySecret = O.getOrUndefined(yield* Config.string("GATEWAY_SECRET").pipe(Config.option))
const token = readNonEmpty(gatewaySecret)
const flowId = O.getOrUndefined(yield* Config.string("FLOW_ID").pipe(Config.option))
const openAiModel = O.getOrUndefined(yield* Config.string("OPENAI_MODEL").pipe(Config.option))
const openAiApiKey = O.getOrUndefined(yield* Config.redacted("OPENAI_API_KEY").pipe(Config.option))
const openAiToken = O.getOrUndefined(yield* Config.redacted("OPENAI_TOKEN").pipe(Config.option))
const port = O.getOrUndefined(yield* Config.string("PORT").pipe(Config.option))
return {
gatewayUrl: options.gatewayUrl ?? gatewayUrl ?? "ws://localhost:8088/api/v1/rpc",
user: options.user ?? user ?? "mcp",
token: options.token ?? token,
flowId: options.flowId ?? flowId ?? "default",
name: options.name ?? "trustgraph",
version: options.version ?? "0.1.0",
mcpPath: options.mcpPath ?? "/mcp",
openAiModel: options.openAiModel ?? openAiModel ?? "gpt-4.1",
openAiApiKey: options.openAiApiKey === undefined
? openAiApiKey ?? openAiToken
: Redacted.make(options.openAiApiKey),
port: options.port ?? parsePort(readNonEmpty(port)),
}
})
export const resolveTrustGraphMcpConfig = (
options: TrustGraphMcpOptions = {},
): TrustGraphMcpConfigShape => ({
gatewayUrl: options.gatewayUrl ?? process.env.GATEWAY_URL ?? "ws://localhost:8088/api/v1/rpc",
user: options.user ?? process.env.USER_ID ?? "mcp",
token: options.token ?? readNonEmpty(process.env.GATEWAY_SECRET),
flowId: options.flowId ?? process.env.FLOW_ID ?? "default",
name: options.name ?? "trustgraph",
version: options.version ?? "0.1.0",
mcpPath: options.mcpPath ?? "/mcp",
openAiModel: options.openAiModel ?? process.env.OPENAI_MODEL ?? "gpt-4.1",
openAiApiKey: options.openAiApiKey ?? readNonEmpty(process.env.OPENAI_API_KEY) ?? readNonEmpty(process.env.OPENAI_TOKEN),
port: resolvePort(options.port),
})
): TrustGraphMcpConfigShape => Effect.runSync(loadTrustGraphMcpConfig(options))
export class TrustGraphMcpConfig extends Context.Service<TrustGraphMcpConfig, TrustGraphMcpConfigShape>()(
"@trustgraph/mcp/server-effect/TrustGraphMcpConfig",
) {
static readonly layer = (options: TrustGraphMcpOptions = {}) =>
Layer.succeed(
Layer.effect(
TrustGraphMcpConfig,
TrustGraphMcpConfig.of(resolveTrustGraphMcpConfig(options)),
loadTrustGraphMcpConfig(options).pipe(Effect.map(TrustGraphMcpConfig.of)),
)
}
@ -1278,17 +1299,14 @@ export class TrustGraphSocket extends Context.Service<TrustGraphSocket, BaseApi>
}
const toErrorMessage = (cause: unknown): string => {
if (cause instanceof Error && cause.message.length > 0) {
if (Predicate.isError(cause) && cause.message.length > 0) {
return cause.message
}
if (typeof cause === "string" && cause.length > 0) {
return cause
}
if (cause !== null && typeof cause === "object" && "message" in cause) {
const message = (cause as { readonly message?: unknown }).message
if (typeof message === "string" && message.length > 0) {
return message
}
if (Predicate.isObject(cause) && Predicate.hasProperty(cause, "message") && Predicate.isString(cause.message) && cause.message.length > 0) {
return cause.message
}
return "TrustGraph MCP tool failed"
}
@ -1313,16 +1331,15 @@ const decodeJsonArrayOrFail = <E>(
const asIriTerm = (value: string | undefined): ClientTerm | undefined =>
value !== undefined && value.length > 0 ? {t: "i", i: value} : undefined
const openAiApiKeyOptions = (apiKey: string | undefined) =>
const openAiApiKeyOptions = (apiKey: Redacted.Redacted | undefined) =>
apiKey === undefined
? {}
: {apiKey: Redacted.make(apiKey)}
: {apiKey}
export const makeOpenAiProviderLayer = (
options: TrustGraphMcpOptions = {},
) => {
const config = resolveTrustGraphMcpConfig(options)
return OpenAiLanguageModel.layer({
const makeOpenAiProviderLayerFromConfig = (
config: TrustGraphMcpConfigShape,
) =>
OpenAiLanguageModel.layer({
model: config.openAiModel,
config: {
strictJsonSchema: true,
@ -1331,7 +1348,15 @@ export const makeOpenAiProviderLayer = (
Layer.provide(OpenAiClient.layer(openAiApiKeyOptions(config.openAiApiKey))),
Layer.provide(FetchHttpClient.layer),
)
}
export const makeOpenAiProviderLayer = (
options: TrustGraphMcpOptions = {},
) =>
Layer.unwrap(
loadTrustGraphMcpConfig(options).pipe(
Effect.map(makeOpenAiProviderLayerFromConfig),
),
)
export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
Effect.gen(function*() {
@ -1344,33 +1369,32 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
const response = yield* model.generateText({
prompt: Prompt.make(prompt).pipe(Prompt.setSystem(system)),
})
return new TextCompletionSuccess({text: response.text})
return TextCompletionSuccess.make({text: response.text})
}),
graph_rag: ({query, entity_limit, triple_limit, collection}) =>
Effect.tryPromise({
try: async () => {
const response = await socket.flow(config.flowId).graphRag(
try: () =>
socket.flow(config.flowId).graphRag(
query,
{
...(entity_limit !== undefined ? {entityLimit: entity_limit} : {}),
...(triple_limit !== undefined ? {tripleLimit: triple_limit} : {}),
},
collection,
)
return new GraphRagSuccess({text: response})
},
catch: (cause) => new GraphRagError({cause, message: toErrorMessage(cause)}),
}),
),
catch: (cause) => GraphRagError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.map((text) => GraphRagSuccess.make({text})),
),
document_rag: ({query, doc_limit, collection}) =>
Effect.tryPromise({
try: async () => {
const response = await socket.flow(config.flowId).documentRag(query, doc_limit, collection)
return new DocumentRagSuccess({text: response})
},
catch: (cause) => new DocumentRagError({cause, message: toErrorMessage(cause)}),
}),
try: () => socket.flow(config.flowId).documentRag(query, doc_limit, collection),
catch: (cause) => DocumentRagError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.map((text) => DocumentRagSuccess.make({text})),
),
agent: ({question}) =>
Effect.callback<AgentSuccess, AgentError>((resume) => {
@ -1382,62 +1406,65 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
(chunk, complete) => {
fullAnswer += chunk
if (complete) {
resume(Effect.succeed(new AgentSuccess({text: fullAnswer})))
resume(Effect.succeed(AgentSuccess.make({text: fullAnswer})))
}
},
(cause) => resume(Effect.fail(new AgentError({cause, message: toErrorMessage(cause)}))),
(cause) => resume(Effect.fail(AgentError.make({cause, message: toErrorMessage(cause)}))),
)
}),
embeddings: ({text}) =>
Effect.tryPromise({
try: async () => {
const vectors = await socket.flow(config.flowId).embeddings([...text])
return new EmbeddingsSuccess({vectors})
},
catch: (cause) => new EmbeddingsError({cause, message: toErrorMessage(cause)}),
}),
try: () => socket.flow(config.flowId).embeddings([...text]),
catch: (cause) => EmbeddingsError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.map((vectors) => EmbeddingsSuccess.make({vectors})),
),
triples_query: ({s, p, o, limit, collection}) =>
Effect.tryPromise({
try: async () => {
const triples = await socket.flow(config.flowId).triplesQuery(
try: () =>
socket.flow(config.flowId).triplesQuery(
asIriTerm(s),
asIriTerm(p),
asIriTerm(o),
limit,
collection,
)
return new TriplesQuerySuccess({triples})
},
catch: (cause) => new TriplesQueryError({cause, message: toErrorMessage(cause)}),
}),
),
catch: (cause) => TriplesQueryError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.map((triples) => TriplesQuerySuccess.make({triples})),
),
graph_embeddings_query: ({query, limit, collection}) =>
Effect.tryPromise({
try: async () => {
const vectors = await socket.flow(config.flowId).embeddings([query])
const entities = await socket.flow(config.flowId).graphEmbeddingsQuery(
vectors[0] ?? [],
limit ?? 10,
collection,
)
return new GraphEmbeddingsQuerySuccess({entities})
},
catch: (cause) => new GraphEmbeddingsQueryError({cause, message: toErrorMessage(cause)}),
}),
try: () => socket.flow(config.flowId).embeddings([query]),
catch: (cause) => GraphEmbeddingsQueryError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((vectors) =>
Effect.tryPromise({
try: () => socket.flow(config.flowId).graphEmbeddingsQuery(
vectors[0] ?? [],
limit ?? 10,
collection,
),
catch: (cause) => GraphEmbeddingsQueryError.make({cause, message: toErrorMessage(cause)}),
})
),
Effect.map((entities) => GraphEmbeddingsQuerySuccess.make({entities})),
),
get_config_all: () =>
Effect.tryPromise({
try: () => socket.config().getConfigAll(),
catch: (cause) => new GetConfigAllError({cause, message: toErrorMessage(cause)}),
catch: (cause) => GetConfigAllError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => new GetConfigAllError({cause, message: toErrorMessage(cause)}),
(cause) => GetConfigAllError.make({cause, message: toErrorMessage(cause)}),
).pipe(
Effect.map((config) => new GetConfigAllSuccess({config})),
Effect.map((config) => GetConfigAllSuccess.make({config})),
)
),
),
@ -1445,14 +1472,14 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
get_config: ({keys}) =>
Effect.tryPromise({
try: () => socket.config().getConfig(keys.map(({type, key}) => ({type, key}))),
catch: (cause) => new GetConfigError({cause, message: toErrorMessage(cause)}),
catch: (cause) => GetConfigError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => new GetConfigError({cause, message: toErrorMessage(cause)}),
(cause) => GetConfigError.make({cause, message: toErrorMessage(cause)}),
).pipe(
Effect.map((config) => new GetConfigSuccess({config})),
Effect.map((config) => GetConfigSuccess.make({config})),
)
),
),
@ -1460,14 +1487,14 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
put_config: ({values}) =>
Effect.tryPromise({
try: () => socket.config().putConfig(values.map(({type, key, value}) => ({type, key, value}))),
catch: (cause) => new PutConfigError({cause, message: toErrorMessage(cause)}),
catch: (cause) => PutConfigError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => new PutConfigError({cause, message: toErrorMessage(cause)}),
(cause) => PutConfigError.make({cause, message: toErrorMessage(cause)}),
).pipe(
Effect.map((response) => new PutConfigSuccess({response})),
Effect.map((response) => PutConfigSuccess.make({response})),
)
),
),
@ -1475,56 +1502,58 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
delete_config: ({type, key}) =>
Effect.tryPromise({
try: () => socket.config().deleteConfig({type, key}),
catch: (cause) => new DeleteConfigError({cause, message: toErrorMessage(cause)}),
catch: (cause) => DeleteConfigError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => new DeleteConfigError({cause, message: toErrorMessage(cause)}),
(cause) => DeleteConfigError.make({cause, message: toErrorMessage(cause)}),
).pipe(
Effect.map((response) => new DeleteConfigSuccess({response})),
Effect.map((response) => DeleteConfigSuccess.make({response})),
)
),
),
get_flows: () =>
Effect.tryPromise({
try: async () => new GetFlowsSuccess({flow_ids: await socket.flows().getFlows()}),
catch: (cause) => new GetFlowsError({cause, message: toErrorMessage(cause)}),
}),
try: () => socket.flows().getFlows(),
catch: (cause) => GetFlowsError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.map((flow_ids) => GetFlowsSuccess.make({flow_ids})),
),
get_flow: ({flow_id}) =>
Effect.tryPromise({
try: () => socket.flows().getFlow(flow_id),
catch: (cause) => new GetFlowError({cause, message: toErrorMessage(cause)}),
catch: (cause) => GetFlowError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => new GetFlowError({cause, message: toErrorMessage(cause)}),
(cause) => GetFlowError.make({cause, message: toErrorMessage(cause)}),
).pipe(
Effect.map((flow) => new GetFlowSuccess({flow})),
Effect.map((flow) => GetFlowSuccess.make({flow})),
)
),
),
start_flow: ({flow_id, blueprint_name, description, parameters}) =>
Effect.tryPromise({
try: async () =>
try: () =>
socket.flows().startFlow(
flow_id,
blueprint_name,
description,
parameters === undefined ? undefined : {...parameters},
),
catch: (cause) => new StartFlowError({cause, message: toErrorMessage(cause)}),
catch: (cause) => StartFlowError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => new StartFlowError({cause, message: toErrorMessage(cause)}),
(cause) => StartFlowError.make({cause, message: toErrorMessage(cause)}),
).pipe(
Effect.map((response) => new StartFlowSuccess({response})),
Effect.map((response) => StartFlowSuccess.make({response})),
)
),
),
@ -1532,14 +1561,14 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
stop_flow: ({flow_id}) =>
Effect.tryPromise({
try: () => socket.flows().stopFlow(flow_id),
catch: (cause) => new StopFlowError({cause, message: toErrorMessage(cause)}),
catch: (cause) => StopFlowError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => new StopFlowError({cause, message: toErrorMessage(cause)}),
(cause) => StopFlowError.make({cause, message: toErrorMessage(cause)}),
).pipe(
Effect.map((response) => new StopFlowSuccess({response})),
Effect.map((response) => StopFlowSuccess.make({response})),
)
),
),
@ -1547,21 +1576,21 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
get_documents: () =>
Effect.tryPromise({
try: () => socket.librarian().getDocuments(),
catch: (cause) => new GetDocumentsError({cause, message: toErrorMessage(cause)}),
catch: (cause) => GetDocumentsError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonArrayOrFail(
value,
(cause) => new GetDocumentsError({cause, message: toErrorMessage(cause)}),
(cause) => GetDocumentsError.make({cause, message: toErrorMessage(cause)}),
).pipe(
Effect.map((documents) => new GetDocumentsSuccess({documents})),
Effect.map((documents) => GetDocumentsSuccess.make({documents})),
)
),
),
load_document: ({document, mime_type, title, comments, tags, id}) =>
Effect.tryPromise({
try: async () =>
try: () =>
socket.librarian().loadDocument(
document,
mime_type,
@ -1570,14 +1599,14 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
tags === undefined ? [] : [...tags],
id,
),
catch: (cause) => new LoadDocumentError({cause, message: toErrorMessage(cause)}),
catch: (cause) => LoadDocumentError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => new LoadDocumentError({cause, message: toErrorMessage(cause)}),
(cause) => LoadDocumentError.make({cause, message: toErrorMessage(cause)}),
).pipe(
Effect.map((response) => new LoadDocumentSuccess({response})),
Effect.map((response) => LoadDocumentSuccess.make({response})),
)
),
),
@ -1585,56 +1614,60 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
remove_document: ({id, collection}) =>
Effect.tryPromise({
try: () => socket.librarian().removeDocument(id, collection),
catch: (cause) => new RemoveDocumentError({cause, message: toErrorMessage(cause)}),
catch: (cause) => RemoveDocumentError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => new RemoveDocumentError({cause, message: toErrorMessage(cause)}),
(cause) => RemoveDocumentError.make({cause, message: toErrorMessage(cause)}),
).pipe(
Effect.map((response) => new RemoveDocumentSuccess({response})),
Effect.map((response) => RemoveDocumentSuccess.make({response})),
)
),
),
get_prompts: () =>
Effect.tryPromise({
try: async () => new GetPromptsSuccess({prompts: await socket.config().getPrompts()}),
catch: (cause) => new GetPromptsError({cause, message: toErrorMessage(cause)}),
}),
try: () => socket.config().getPrompts(),
catch: (cause) => GetPromptsError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.map((prompts) => GetPromptsSuccess.make({prompts})),
),
get_prompt: ({id}) =>
Effect.tryPromise({
try: () => socket.config().getPrompt(id),
catch: (cause) => new GetPromptError({cause, message: toErrorMessage(cause)}),
catch: (cause) => GetPromptError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => new GetPromptError({cause, message: toErrorMessage(cause)}),
(cause) => GetPromptError.make({cause, message: toErrorMessage(cause)}),
).pipe(
Effect.map((prompt) => new GetPromptSuccess({prompt})),
Effect.map((prompt) => GetPromptSuccess.make({prompt})),
)
),
),
get_knowledge_cores: () =>
Effect.tryPromise({
try: async () => new GetKnowledgeCoresSuccess({ids: await socket.knowledge().getKnowledgeCores()}),
catch: (cause) => new GetKnowledgeCoresError({cause, message: toErrorMessage(cause)}),
}),
try: () => socket.knowledge().getKnowledgeCores(),
catch: (cause) => GetKnowledgeCoresError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.map((ids) => GetKnowledgeCoresSuccess.make({ids})),
),
delete_kg_core: ({id, collection}) =>
Effect.tryPromise({
try: () => socket.knowledge().deleteKgCore(id, collection),
catch: (cause) => new DeleteKgCoreError({cause, message: toErrorMessage(cause)}),
catch: (cause) => DeleteKgCoreError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => new DeleteKgCoreError({cause, message: toErrorMessage(cause)}),
(cause) => DeleteKgCoreError.make({cause, message: toErrorMessage(cause)}),
).pipe(
Effect.map((response) => new DeleteKgCoreSuccess({response})),
Effect.map((response) => DeleteKgCoreSuccess.make({response})),
)
),
),
@ -1642,14 +1675,14 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
load_kg_core: ({id, flow, collection}) =>
Effect.tryPromise({
try: () => socket.knowledge().loadKgCore(id, flow, collection),
catch: (cause) => new LoadKgCoreError({cause, message: toErrorMessage(cause)}),
catch: (cause) => LoadKgCoreError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => new LoadKgCoreError({cause, message: toErrorMessage(cause)}),
(cause) => LoadKgCoreError.make({cause, message: toErrorMessage(cause)}),
).pipe(
Effect.map((response) => new LoadKgCoreSuccess({response})),
Effect.map((response) => LoadKgCoreSuccess.make({response})),
)
),
),
@ -1689,13 +1722,12 @@ export const TrustGraphMcpHttpApiRoutes = HttpApiBuilder.layer(
Layer.provide(TrustGraphMcpHttpApiHandlers),
)
export const makeTrustGraphMcpHttpLayer = (
options: TrustGraphMcpOptions = {},
const makeTrustGraphMcpHttpLayerFromConfig = (
config: TrustGraphMcpConfigShape,
) => {
const config = resolveTrustGraphMcpConfig(options)
const tools = McpServer.toolkit(TrustGraphMcpToolkit).pipe(
Layer.provide(TrustGraphMcpToolkitLive),
Layer.provide(makeOpenAiProviderLayer(config)),
Layer.provide(makeOpenAiProviderLayerFromConfig(config)),
)
return Layer.mergeAll(
@ -1708,18 +1740,31 @@ export const makeTrustGraphMcpHttpLayer = (
path: config.mcpPath,
})),
Layer.provide(TrustGraphSocket.layer),
Layer.provide(TrustGraphMcpConfig.layer(config)),
Layer.provide(Layer.succeed(TrustGraphMcpConfig, TrustGraphMcpConfig.of(config))),
)
}
export const makeTrustGraphMcpHttpServerLayer = (
options: TrustGraphMcpOptions = {},
) => {
const config = resolveTrustGraphMcpConfig(options)
return HttpRouter.serve(makeTrustGraphMcpHttpLayer(config)).pipe(
Layer.provide(BunHttpServer.layer({port: config.port})),
) =>
Layer.unwrap(
loadTrustGraphMcpConfig(options).pipe(
Effect.map((config) =>
HttpRouter.serve(makeTrustGraphMcpHttpLayerFromConfig(config)).pipe(
Layer.provide(BunHttpServer.layer({port: config.port})),
)
),
),
)
export const makeTrustGraphMcpHttpLayer = (
options: TrustGraphMcpOptions = {},
) =>
Layer.unwrap(
loadTrustGraphMcpConfig(options).pipe(
Effect.map(makeTrustGraphMcpHttpLayerFromConfig),
),
)
}
export const runHttp = (options: TrustGraphMcpOptions = {}): void => {
Layer.launch(makeTrustGraphMcpHttpServerLayer(options)).pipe(BunRuntime.runMain)

View file

@ -1,16 +1,77 @@
/**
* TrustGraph MCP server.
* TrustGraph MCP stdio compatibility server.
*
* Exposes TrustGraph capabilities as MCP tools for AI assistants.
* Uses the vendored @trustgraph/client for all gateway communication.
*
* Python reference: trustgraph-mcp/trustgraph/mcp_server/mcp.py
* This keeps the original @modelcontextprotocol/sdk entry points available,
* while moving gateway calls, callback bridging, JSON encoding, and config
* reads behind Effect values.
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { createTrustGraphSocket, type BaseApi, type Term } from "@trustgraph/client";
import {McpServer} from "@modelcontextprotocol/sdk/server/mcp.js";
import {StdioServerTransport} from "@modelcontextprotocol/sdk/server/stdio.js";
import {NodeRuntime} from "@effect/platform-node";
import {createTrustGraphSocket, type BaseApi, type Term} from "@trustgraph/client";
import {Effect, Layer, ManagedRuntime} from "effect";
import * as Predicate from "effect/Predicate";
import * as S from "effect/Schema";
import {z} from "zod";
import {loadTrustGraphMcpConfig} from "./server-effect.js";
interface ToolTextContent {
readonly type: "text"
readonly text: string
}
interface ToolTextResult extends Record<string, unknown> {
readonly content: Array<ToolTextContent>
}
class StdioMcpError extends S.TaggedErrorClass<StdioMcpError>()(
"StdioMcpError",
{
cause: S.DefectWithStack,
message: S.String,
},
) {
}
const encodeJsonText = S.encodeUnknownEffect(S.UnknownFromJsonString);
const toErrorMessage = (cause: unknown): string => {
if (Predicate.isError(cause) && cause.message.length > 0) {
return cause.message;
}
if (Predicate.isString(cause) && cause.length > 0) {
return cause;
}
if (Predicate.isObject(cause) && Predicate.hasProperty(cause, "message") && Predicate.isString(cause.message) && cause.message.length > 0) {
return cause.message;
}
return "TrustGraph MCP stdio operation failed";
};
const stdioMcpError = (cause: unknown) =>
StdioMcpError.make({cause, message: toErrorMessage(cause)});
const textResult = (text: string): ToolTextResult => ({
content: [{type: "text", text}],
});
const gatewayRequest = <A>(request: () => Promise<A>) =>
Effect.tryPromise({
try: request,
catch: stdioMcpError,
});
const jsonText = (value: unknown) =>
encodeJsonText(value).pipe(
Effect.mapError(stdioMcpError),
);
const runTextTool = (effect: Effect.Effect<string, StdioMcpError>) =>
Effect.runPromise(effect.pipe(Effect.map(textResult)));
const runJsonTool = (effect: Effect.Effect<unknown, StdioMcpError>) =>
Effect.runPromise(effect.pipe(Effect.flatMap(jsonText), Effect.map(textResult)));
export function createMcpServer(config: {
gatewayUrl: string;
@ -34,7 +95,6 @@ export function createMcpServer(config: {
// ===================== Flow-scoped tools =====================
// --- Text Completion ---
server.tool(
"text_completion",
"Run a text completion using the configured LLM",
@ -42,14 +102,10 @@ export function createMcpServer(config: {
system: z.string().describe("System prompt"),
prompt: z.string().describe("User prompt"),
},
async ({ system, prompt }) => {
const flow = socket.flow(flowId);
const response = await flow.textCompletion(system, prompt);
return { content: [{ type: "text" as const, text: response }] };
},
({system, prompt}) =>
runTextTool(gatewayRequest(() => socket.flow(flowId).textCompletion(system, prompt))),
);
// --- Graph RAG ---
server.tool(
"graph_rag",
"Query the knowledge graph using RAG",
@ -59,21 +115,21 @@ export function createMcpServer(config: {
triple_limit: z.number().optional().describe("Max triples per entity"),
collection: z.string().optional().describe("Collection name"),
},
async ({ query, entity_limit, triple_limit, collection }) => {
const flow = socket.flow(flowId);
const response = await flow.graphRag(
query,
{
...(entity_limit !== undefined ? { entityLimit: entity_limit } : {}),
...(triple_limit !== undefined ? { tripleLimit: triple_limit } : {}),
},
collection,
);
return { content: [{ type: "text" as const, text: response }] };
},
({query, entity_limit, triple_limit, collection}) =>
runTextTool(
gatewayRequest(() =>
socket.flow(flowId).graphRag(
query,
{
...(entity_limit !== undefined ? {entityLimit: entity_limit} : {}),
...(triple_limit !== undefined ? {tripleLimit: triple_limit} : {}),
},
collection,
)
),
),
);
// --- Document RAG ---
server.tool(
"document_rag",
"Query documents using RAG",
@ -82,56 +138,45 @@ export function createMcpServer(config: {
doc_limit: z.number().optional().describe("Max documents to retrieve"),
collection: z.string().optional().describe("Collection name"),
},
async ({ query, doc_limit, collection }) => {
const flow = socket.flow(flowId);
const response = await flow.documentRag(query, doc_limit, collection);
return { content: [{ type: "text" as const, text: response }] };
},
({query, doc_limit, collection}) =>
runTextTool(gatewayRequest(() => socket.flow(flowId).documentRag(query, doc_limit, collection))),
);
// --- Agent ---
server.tool(
"agent",
"Ask the TrustGraph agent a question",
{
question: z.string().describe("Question for the agent"),
},
async ({ question }) => {
const flow = socket.flow(flowId);
let fullAnswer = "";
await new Promise<void>((resolve, reject) => {
flow.agent(
question,
() => {}, // think — ignore for MCP
() => {}, // observe — ignore for MCP
(chunk, complete) => {
fullAnswer += chunk;
if (complete) resolve();
},
(err) => reject(new Error(err)),
);
});
return { content: [{ type: "text" as const, text: fullAnswer }] };
},
({question}) =>
runTextTool(
Effect.callback<string, StdioMcpError>((resume) => {
let fullAnswer = "";
socket.flow(flowId).agent(
question,
() => {},
() => {},
(chunk, complete) => {
fullAnswer += chunk;
if (complete) {
resume(Effect.succeed(fullAnswer));
}
},
(cause) => resume(Effect.fail(stdioMcpError(cause))),
);
}),
),
);
// --- Embeddings ---
server.tool(
"embeddings",
"Generate text embeddings",
{
text: z.array(z.string()).describe("Texts to embed"),
},
async ({ text }) => {
const flow = socket.flow(flowId);
const vectors = await flow.embeddings(text);
return { content: [{ type: "text" as const, text: JSON.stringify(vectors) }] };
},
({text}) => runJsonTool(gatewayRequest(() => socket.flow(flowId).embeddings(text))),
);
// --- Triples Query ---
server.tool(
"triples_query",
"Query the knowledge graph for triples matching a pattern",
@ -142,17 +187,16 @@ export function createMcpServer(config: {
limit: z.number().optional().describe("Max results"),
collection: z.string().optional().describe("Collection name"),
},
async ({ s, p, o, limit, collection }) => {
const flow = socket.flow(flowId);
const sTerm: Term | undefined = s !== undefined && s.length > 0 ? { t: "i", i: s } : undefined;
const pTerm: Term | undefined = p !== undefined && p.length > 0 ? { t: "i", i: p } : undefined;
const oTerm: Term | undefined = o !== undefined && o.length > 0 ? { t: "i", i: o } : undefined;
const triples = await flow.triplesQuery(sTerm, pTerm, oTerm, limit, collection);
return { content: [{ type: "text" as const, text: JSON.stringify(triples, null, 2) }] };
({s, p, o, limit, collection}) => {
const sTerm: Term | undefined = s !== undefined && s.length > 0 ? {t: "i", i: s} : undefined;
const pTerm: Term | undefined = p !== undefined && p.length > 0 ? {t: "i", i: p} : undefined;
const oTerm: Term | undefined = o !== undefined && o.length > 0 ? {t: "i", i: o} : undefined;
return runJsonTool(
gatewayRequest(() => socket.flow(flowId).triplesQuery(sTerm, pTerm, oTerm, limit, collection)),
);
},
);
// --- Graph Embeddings Query ---
server.tool(
"graph_embeddings_query",
"Find entities similar to a text query using vector embeddings",
@ -161,17 +205,20 @@ export function createMcpServer(config: {
limit: z.number().optional().describe("Max results"),
collection: z.string().optional().describe("Collection name"),
},
async ({ query, limit, collection }) => {
const flow = socket.flow(flowId);
// First embed the query, then search
const vectors = await flow.embeddings([query]);
const entities = await flow.graphEmbeddingsQuery(
vectors[0],
limit ?? 10,
collection,
);
return { content: [{ type: "text" as const, text: JSON.stringify(entities, null, 2) }] };
},
({query, limit, collection}) =>
runJsonTool(
gatewayRequest(() => socket.flow(flowId).embeddings([query])).pipe(
Effect.flatMap((vectors) =>
gatewayRequest(() =>
socket.flow(flowId).graphEmbeddingsQuery(
vectors[0] ?? [],
limit ?? 10,
collection,
)
)
),
),
),
);
// ===================== Config tools =====================
@ -180,11 +227,7 @@ export function createMcpServer(config: {
"get_config_all",
"Get all configuration values",
{},
async () => {
const cfg = socket.config();
const resp = await cfg.getConfigAll();
return { content: [{ type: "text" as const, text: JSON.stringify(resp, null, 2) }] };
},
() => runJsonTool(gatewayRequest(() => socket.config().getConfigAll())),
);
server.tool(
@ -198,11 +241,7 @@ export function createMcpServer(config: {
}),
).describe("Config keys to retrieve"),
},
async ({ keys }) => {
const cfg = socket.config();
const resp = await cfg.getConfig(keys);
return { content: [{ type: "text" as const, text: JSON.stringify(resp, null, 2) }] };
},
({keys}) => runJsonTool(gatewayRequest(() => socket.config().getConfig(keys))),
);
server.tool(
@ -217,11 +256,7 @@ export function createMcpServer(config: {
}),
).describe("Key-value entries to set"),
},
async ({ values }) => {
const cfg = socket.config();
const resp = await cfg.putConfig(values);
return { content: [{ type: "text" as const, text: JSON.stringify(resp) }] };
},
({values}) => runJsonTool(gatewayRequest(() => socket.config().putConfig(values))),
);
server.tool(
@ -231,11 +266,7 @@ export function createMcpServer(config: {
type: z.string().describe("Config type"),
key: z.string().describe("Config key"),
},
async ({ type, key }) => {
const cfg = socket.config();
const resp = await cfg.deleteConfig({ type, key });
return { content: [{ type: "text" as const, text: JSON.stringify(resp) }] };
},
({type, key}) => runJsonTool(gatewayRequest(() => socket.config().deleteConfig({type, key}))),
);
// ===================== Flow management tools =====================
@ -244,11 +275,7 @@ export function createMcpServer(config: {
"get_flows",
"List all available flows",
{},
async () => {
const flows = socket.flows();
const ids = await flows.getFlows();
return { content: [{ type: "text" as const, text: JSON.stringify(ids, null, 2) }] };
},
() => runJsonTool(gatewayRequest(() => socket.flows().getFlows())),
);
server.tool(
@ -257,11 +284,7 @@ export function createMcpServer(config: {
{
flow_id: z.string().describe("Flow ID to retrieve"),
},
async ({ flow_id }) => {
const flows = socket.flows();
const def = await flows.getFlow(flow_id);
return { content: [{ type: "text" as const, text: JSON.stringify(def, null, 2) }] };
},
({flow_id}) => runJsonTool(gatewayRequest(() => socket.flows().getFlow(flow_id))),
);
server.tool(
@ -273,11 +296,10 @@ export function createMcpServer(config: {
description: z.string().describe("Flow description"),
parameters: z.record(z.unknown()).optional().describe("Optional flow parameters"),
},
async ({ flow_id, blueprint_name, description, parameters }) => {
const flows = socket.flows();
const resp = await flows.startFlow(flow_id, blueprint_name, description, parameters);
return { content: [{ type: "text" as const, text: JSON.stringify(resp, null, 2) }] };
},
({flow_id, blueprint_name, description, parameters}) =>
runJsonTool(
gatewayRequest(() => socket.flows().startFlow(flow_id, blueprint_name, description, parameters)),
),
);
server.tool(
@ -286,11 +308,7 @@ export function createMcpServer(config: {
{
flow_id: z.string().describe("Flow ID to stop"),
},
async ({ flow_id }) => {
const flows = socket.flows();
const resp = await flows.stopFlow(flow_id);
return { content: [{ type: "text" as const, text: JSON.stringify(resp, null, 2) }] };
},
({flow_id}) => runJsonTool(gatewayRequest(() => socket.flows().stopFlow(flow_id))),
);
// ===================== Library (document) tools =====================
@ -299,11 +317,7 @@ export function createMcpServer(config: {
"get_documents",
"List all documents in the library",
{},
async () => {
const lib = socket.librarian();
const docs = await lib.getDocuments();
return { content: [{ type: "text" as const, text: JSON.stringify(docs, null, 2) }] };
},
() => runJsonTool(gatewayRequest(() => socket.librarian().getDocuments())),
);
server.tool(
@ -317,18 +331,19 @@ export function createMcpServer(config: {
tags: z.array(z.string()).optional().describe("Document tags"),
id: z.string().optional().describe("Optional document ID"),
},
async ({ document, mime_type, title, comments, tags, id }) => {
const lib = socket.librarian();
const resp = await lib.loadDocument(
document,
mime_type,
title,
comments ?? "",
tags ?? [],
id,
);
return { content: [{ type: "text" as const, text: JSON.stringify(resp, null, 2) }] };
},
({document, mime_type, title, comments, tags, id}) =>
runJsonTool(
gatewayRequest(() =>
socket.librarian().loadDocument(
document,
mime_type,
title,
comments ?? "",
tags ?? [],
id,
)
),
),
);
server.tool(
@ -338,11 +353,7 @@ export function createMcpServer(config: {
id: z.string().describe("Document ID to remove"),
collection: z.string().optional().describe("Collection name"),
},
async ({ id, collection }) => {
const lib = socket.librarian();
const resp = await lib.removeDocument(id, collection);
return { content: [{ type: "text" as const, text: JSON.stringify(resp) }] };
},
({id, collection}) => runJsonTool(gatewayRequest(() => socket.librarian().removeDocument(id, collection))),
);
// ===================== Prompt tools =====================
@ -351,11 +362,7 @@ export function createMcpServer(config: {
"get_prompts",
"List available prompt templates",
{},
async () => {
const cfg = socket.config();
const prompts = await cfg.getPrompts();
return { content: [{ type: "text" as const, text: JSON.stringify(prompts, null, 2) }] };
},
() => runJsonTool(gatewayRequest(() => socket.config().getPrompts())),
);
server.tool(
@ -364,11 +371,7 @@ export function createMcpServer(config: {
{
id: z.string().describe("Prompt template ID"),
},
async ({ id }) => {
const cfg = socket.config();
const prompt = await cfg.getPrompt(id);
return { content: [{ type: "text" as const, text: JSON.stringify(prompt, null, 2) }] };
},
({id}) => runJsonTool(gatewayRequest(() => socket.config().getPrompt(id))),
);
// ===================== Knowledge core tools =====================
@ -377,11 +380,7 @@ export function createMcpServer(config: {
"get_knowledge_cores",
"List available knowledge graph cores",
{},
async () => {
const knowledge = socket.knowledge();
const cores = await knowledge.getKnowledgeCores();
return { content: [{ type: "text" as const, text: JSON.stringify(cores, null, 2) }] };
},
() => runJsonTool(gatewayRequest(() => socket.knowledge().getKnowledgeCores())),
);
server.tool(
@ -391,11 +390,7 @@ export function createMcpServer(config: {
id: z.string().describe("Knowledge core ID"),
collection: z.string().optional().describe("Collection name"),
},
async ({ id, collection }) => {
const knowledge = socket.knowledge();
const resp = await knowledge.deleteKgCore(id, collection);
return { content: [{ type: "text" as const, text: JSON.stringify(resp) }] };
},
({id, collection}) => runJsonTool(gatewayRequest(() => socket.knowledge().deleteKgCore(id, collection))),
);
server.tool(
@ -406,31 +401,42 @@ export function createMcpServer(config: {
flow: z.string().describe("Flow to use for loading"),
collection: z.string().optional().describe("Collection name"),
},
async ({ id, flow, collection }) => {
const knowledge = socket.knowledge();
const resp = await knowledge.loadKgCore(id, flow, collection);
return { content: [{ type: "text" as const, text: JSON.stringify(resp) }] };
},
({id, flow, collection}) => runJsonTool(gatewayRequest(() => socket.knowledge().loadKgCore(id, flow, collection))),
);
return { server, socket };
return {server, socket};
}
export async function run(): Promise<void> {
const { server, socket } = createMcpServer({
gatewayUrl: process.env.GATEWAY_URL ?? "ws://localhost:8088/api/v1/rpc",
user: process.env.USER_ID ?? "mcp",
flowId: process.env.FLOW_ID ?? "default",
...(process.env.GATEWAY_SECRET !== undefined
? { token: process.env.GATEWAY_SECRET }
: {}),
});
export const runProgram = Effect.gen(function*() {
const config = yield* loadTrustGraphMcpConfig();
const serverConfig = {
gatewayUrl: config.gatewayUrl,
user: config.user,
flowId: config.flowId,
...(config.token === undefined ? {} : {token: config.token}),
};
const {server, socket} = createMcpServer(serverConfig);
const transport = new StdioServerTransport();
await server.connect(transport);
process.on("SIGINT", () => {
socket.close();
process.exit(0);
yield* Effect.tryPromise({
try: () => server.connect(transport),
catch: stdioMcpError,
});
yield* Effect.sync(() => {
process.on("SIGINT", () => {
socket.close();
process.exit(0);
});
});
});
const stdioRuntime = ManagedRuntime.make(Layer.empty);
export function run(): Promise<void> {
return stdioRuntime.runPromise(runProgram);
}
export function runMain(): void {
NodeRuntime.runMain(runProgram);
}