This is an automated email from the ASF dual-hosted git repository. spmallette pushed a commit to branch js-translator in repository https://gitbox.apache.org/repos/asf/tinkerpop.git
commit 76f4fa937e85fb2a1c509b1cf91f97bbe7c5194e Author: Stephen Mallette <[email protected]> AuthorDate: Tue Mar 10 09:01:04 2026 -0400 Added translator tool to gremlin-mcp The translator only converts canonical Gremlin to variants. --- .../javascript/gremlin-javascript/package.json | 8 +- gremlin-mcp/src/main/javascript/package.json | 4 +- .../main/javascript/scripts/generate-step-names.js | 99 +++++++ gremlin-mcp/src/main/javascript/src/config.ts | 31 +-- .../src/main/javascript/src/connectivity-state.ts | 68 +++++ gremlin-mcp/src/main/javascript/src/constants.ts | 1 + .../src/main/javascript/src/gremlin/client.ts | 31 +-- .../src/main/javascript/src/gremlin/connection.ts | 231 +++++++++------- .../src/main/javascript/src/gremlin/query-utils.ts | 15 +- .../javascript/src/gremlin/schema-generator.ts | 8 +- .../src/main/javascript/src/gremlin/schema.ts | 17 +- .../src/main/javascript/src/gremlin/service.ts | 119 +++++--- .../src/main/javascript/src/gremlin/types.ts | 2 +- .../src/main/javascript/src/handlers/resources.ts | 84 ++++-- .../src/main/javascript/src/handlers/tools.ts | 248 +++++++++++++++-- gremlin-mcp/src/main/javascript/src/server.ts | 218 ++++++++++----- .../src/main/javascript/src/translator/index.ts | 72 +++++ .../src/main/javascript/src/translator/llm.ts | 66 +++++ .../src/translator/normalizers/shared.ts | 69 +++++ .../main/javascript/src/translator/stepNames.ts | 305 +++++++++++++++++++++ .../src/main/javascript/tests/config.test.ts | 33 ++- .../tests/resource-read.integration.test.ts | 145 ++++++++++ .../src/main/javascript/tests/resources.test.ts | 133 +++++++++ .../gremlin/language/translator/translations.json | 251 ++++++++++++++++- 24 files changed, 1944 insertions(+), 314 deletions(-) diff --git a/gremlin-javascript/src/main/javascript/gremlin-javascript/package.json b/gremlin-javascript/src/main/javascript/gremlin-javascript/package.json index 74256e8e25..bbe04bd3ac 100644 --- a/gremlin-javascript/src/main/javascript/gremlin-javascript/package.json +++ b/gremlin-javascript/src/main/javascript/gremlin-javascript/package.json @@ -25,7 +25,13 @@ "require": "./build/cjs/index.cjs" }, "./language": { - "import": "./build/esm/language/index.js" + "import": "./build/esm/language/index.js", + "types": "./build/esm/language/index.d.ts" + } + }, + "typesVersions": { + "*": { + "language": ["./build/esm/language/index.d.ts"] } }, "files": [ diff --git a/gremlin-mcp/src/main/javascript/package.json b/gremlin-mcp/src/main/javascript/package.json index 709bc9d62d..80125e30c1 100644 --- a/gremlin-mcp/src/main/javascript/package.json +++ b/gremlin-mcp/src/main/javascript/package.json @@ -8,7 +8,8 @@ "gremlin-mcp": "dist/server.js" }, "scripts": { - "build": "tsc && chmod +x dist/server.js", + "generate-step-names": "node scripts/generate-step-names.js", + "build": "npm run generate-step-names && tsc && chmod +x dist/server.js", "dev": "tsx src/server.ts", "start": "node dist/server.js", "test": "jest", @@ -49,6 +50,7 @@ "@types/gremlin": "^3.6.7", "effect": "^3.17.9", "gremlin": "^3.7.4", + "gremlin-language": "file:../../../../gremlin-javascript/src/main/javascript/gremlin-javascript", "winston": "^3.17.0", "zod": "^3.25.76" }, diff --git a/gremlin-mcp/src/main/javascript/scripts/generate-step-names.js b/gremlin-mcp/src/main/javascript/scripts/generate-step-names.js new file mode 100644 index 0000000000..eca91f6c24 --- /dev/null +++ b/gremlin-mcp/src/main/javascript/scripts/generate-step-names.js @@ -0,0 +1,99 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Generates src/translator/stepNames.ts from the Gremlin.g4 grammar. + * + * Extracts all traversalMethod_xxx and traversalSourceSpawnMethod_xxx rule names, + * derives camelCase canonical step names and their PascalCase equivalents used by + * Go and .NET dialects. + * + * Run via: npm run generate-step-names + */ + +import { readFileSync, writeFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const grammarPath = join( + __dirname, + '../../../../../gremlin-language/src/main/antlr4/Gremlin.g4' +); +const outputPath = join(__dirname, '../src/translator/stepNames.ts'); + +const grammar = readFileSync(grammarPath, 'utf-8'); + +const stepNames = new Set(); +for (const line of grammar.split('\n')) { + const match = line.match(/^traversal(?:Method|SourceSpawnMethod)_([a-zA-Z]+)/); + if (match) { + stepNames.add(match[1]); + } +} + +const sorted = [...stepNames].sort(); + +const camelCaseEntries = sorted.map(n => ` '${n}',`).join('\n'); +const pascalCaseEntries = sorted + .map(n => ` '${n[0].toUpperCase()}${n.slice(1)}',`) + .join('\n'); + +const content = `/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Generated by scripts/generate-step-names.js — do not edit manually. +// Re-run \`npm run generate-step-names\` after changes to Gremlin.g4. + +/** + * Canonical camelCase Gremlin step names derived from the gremlin-language grammar. + * Used to safely identify step names during normalization. + */ +export const GREMLIN_STEP_NAMES: ReadonlySet<string> = new Set([ +${camelCaseEntries} +]); + +/** + * PascalCase equivalents of Gremlin step names, as used by Go and .NET dialects. + * Used as an allowlist when converting PascalCase tokens to camelCase during normalization. + */ +export const GREMLIN_STEP_NAMES_PASCAL: ReadonlySet<string> = new Set([ +${pascalCaseEntries} +]); +`; + +writeFileSync(outputPath, content, 'utf-8'); +console.log(`Generated ${outputPath} with ${stepNames.size} step names.`); diff --git a/gremlin-mcp/src/main/javascript/src/config.ts b/gremlin-mcp/src/main/javascript/src/config.ts index 1a9b3b9395..ced9b09d7b 100644 --- a/gremlin-mcp/src/main/javascript/src/config.ts +++ b/gremlin-mcp/src/main/javascript/src/config.ts @@ -110,11 +110,12 @@ const parseCommaSeparatedList = (value: string): string[] => .filter(s => s.length > 0); /** - * GREMLIN_MCP_ENDPOINT: string, required. Gremlin Server compatible websocket endpoint. + * GREMLIN_MCP_ENDPOINT: string, optional. Gremlin Server compatible websocket endpoint. + * When omitted the server starts in offline mode — translate/format tools remain available + * but graph tools (query, schema, status) will return a "no server configured" error. */ -const GremlinMcpEndpointConfig = pipe( - Config.string('GREMLIN_MCP_ENDPOINT'), - Config.mapOrFail(parseEndpoint) +const GremlinMcpEndpointConfig = Config.option( + pipe(Config.string('GREMLIN_MCP_ENDPOINT'), Config.mapOrFail(parseEndpoint)) ); /** @@ -218,21 +219,13 @@ const GremlinMcpSchemaIncludeCountsConfig = Config.withDefault( * Ensures host, port, traversalSource, useSSL, username, password, and idleTimeout are present and valid. * Returns a validated config object or throws ConfigError on failure. */ -const ConnectionConfig = pipe( - Config.all({ - endpoint: GremlinMcpEndpointConfig, - useSSL: GremlinMcpUseSslConfig, - username: GremlinMcpUsernameConfig, - password: GremlinMcpPasswordConfig, - idleTimeout: GremlinMcpIdleTimeoutConfig, - }), - Config.map(({ endpoint, ...rest }) => ({ - host: endpoint.host, - port: endpoint.port, - traversalSource: endpoint.traversalSource, - ...rest, - })) -); +const ConnectionConfig = Config.all({ + endpoint: GremlinMcpEndpointConfig, + useSSL: GremlinMcpUseSslConfig, + username: GremlinMcpUsernameConfig, + password: GremlinMcpPasswordConfig, + idleTimeout: GremlinMcpIdleTimeoutConfig, +}); /** * SchemaDiscoveryConfig: Aggregates and validates all schema discovery-related environment variables. diff --git a/gremlin-mcp/src/main/javascript/src/connectivity-state.ts b/gremlin-mcp/src/main/javascript/src/connectivity-state.ts new file mode 100644 index 0000000000..22e72a1d4e --- /dev/null +++ b/gremlin-mcp/src/main/javascript/src/connectivity-state.ts @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { GremlinConnectionError } from './errors.js'; + +export type GremlinConnectivityState = 'unknown' | 'connected' | 'disconnected'; + +type ConnectivityListener = ( + next: GremlinConnectivityState, + previous: GremlinConnectivityState +) => void; + +let connectivityState: GremlinConnectivityState = 'unknown'; +const listeners = new Set<ConnectivityListener>(); + +const transitionConnectivityState = (next: GremlinConnectivityState): void => { + const previous = connectivityState; + if (previous === next) { + return; + } + connectivityState = next; + for (const listener of listeners) { + listener(next, previous); + } +}; + +export const markGremlinConnected = (): void => { + transitionConnectivityState('connected'); +}; + +export const markGremlinDisconnected = (): void => { + transitionConnectivityState('disconnected'); +}; + +export const getGremlinConnectivityState = (): GremlinConnectivityState => connectivityState; + +export const onGremlinConnectivityStateChange = (listener: ConnectivityListener): (() => void) => { + listeners.add(listener); + return () => listeners.delete(listener); +}; + +export const isGremlinConnectionError = (error: unknown): error is GremlinConnectionError => + typeof error === 'object' && + error !== null && + '_tag' in error && + (error as { _tag?: string })._tag === 'GremlinConnectionError'; + +// Test-only utility to reset global singleton state between test cases. +export const resetGremlinConnectivityStateForTests = (): void => { + connectivityState = 'unknown'; + listeners.clear(); +}; diff --git a/gremlin-mcp/src/main/javascript/src/constants.ts b/gremlin-mcp/src/main/javascript/src/constants.ts index eeee0796a1..8a71ca496e 100644 --- a/gremlin-mcp/src/main/javascript/src/constants.ts +++ b/gremlin-mcp/src/main/javascript/src/constants.ts @@ -45,6 +45,7 @@ export const TOOL_NAMES = { RUN_GREMLIN_QUERY: 'run_gremlin_query', REFRESH_SCHEMA_CACHE: 'refresh_schema_cache', FORMAT_GREMLIN_QUERY: 'format_gremlin_query', + TRANSLATE_GREMLIN_QUERY: 'translate_gremlin_query', } as const; // Default Configuration Values diff --git a/gremlin-mcp/src/main/javascript/src/gremlin/client.ts b/gremlin-mcp/src/main/javascript/src/gremlin/client.ts index 83563d38c9..4c5392d2f4 100644 --- a/gremlin-mcp/src/main/javascript/src/gremlin/client.ts +++ b/gremlin-mcp/src/main/javascript/src/gremlin/client.ts @@ -17,25 +17,24 @@ * under the License. */ -import { Context } from 'effect'; +import { Context, Effect } from 'effect'; +import type { GremlinConnectionError } from '../errors.js'; import type { ConnectionState } from './types.js'; /** - * Represents the Gremlin client as a service in the Effect context. + * Gremlin client service providing lazy, reconnect-capable connection management. * - * This service provides access to the active Gremlin connection state, - * including the client, connection, and traversal source (`g`). + * `getConnection` returns the existing connection from the cache, or creates a new + * one if none exists. Fails with `GremlinConnectionError` if no endpoint is configured + * or if the connection attempt fails. * - * @example - * ```typescript - * import { Effect } from 'effect'; - * import { GremlinClient } from './client.js'; - * - * const myEffect = Effect.gen(function* () { - * const gremlin = yield* GremlinClient; - * const count = yield* Effect.tryPromise(() => gremlin.g.V().count().next()); - * return count.value; - * }); - * ``` + * `invalidate` closes the current connection (if any) and clears the cache so the + * next `getConnection` call creates a fresh connection. */ -export class GremlinClient extends Context.Tag('GremlinClient')<GremlinClient, ConnectionState>() {} +export class GremlinClient extends Context.Tag('GremlinClient')< + GremlinClient, + { + readonly getConnection: Effect.Effect<ConnectionState, GremlinConnectionError>; + readonly invalidate: Effect.Effect<void, never>; + } +>() {} diff --git a/gremlin-mcp/src/main/javascript/src/gremlin/connection.ts b/gremlin-mcp/src/main/javascript/src/gremlin/connection.ts index a85fbf3362..4f2b710e4c 100644 --- a/gremlin-mcp/src/main/javascript/src/gremlin/connection.ts +++ b/gremlin-mcp/src/main/javascript/src/gremlin/connection.ts @@ -20,128 +20,171 @@ /** * @fileoverview Provides a live implementation of the GremlinClient service. * - * This module defines the `GremlinClientLive` layer, which is responsible for - * creating and managing the lifecycle of a Gremlin database connection. - * The layer uses `Effect.Layer` to provide the `GremlinClient` service - * to the application's context, ensuring that the connection is acquired - * when the layer is built and released when the application shuts down. + * At layer construction time, checks whether GREMLIN_MCP_ENDPOINT is configured: + * + * - **Offline mode** (no endpoint): `getConnection` always fails immediately with a + * clear error. No gremlin driver objects (Client, DriverRemoteConnection, etc.) + * are ever instantiated. + * + * - **Online mode** (endpoint configured): connection is created lazily on first use + * and cached. On invalidation the connection is closed and the cache cleared, so + * the next `getConnection` creates a fresh connection. */ -import { Effect, Layer, Option, Redacted } from 'effect'; +import { Effect, Layer, Option, Redacted, Ref, pipe } from 'effect'; import gremlin from 'gremlin'; import { AppConfig } from '../config.js'; import { Errors } from '../errors.js'; import { GremlinClient } from './client.js'; import type { ConnectionState } from './types.js'; +import type { GremlinConnectionError } from '../errors.js'; const { PlainTextSaslAuthenticator } = gremlin.driver.auth; const { Client, DriverRemoteConnection } = gremlin.driver; const { AnonymousTraversalSource } = gremlin.process; +const NO_ENDPOINT_MSG = + 'No Gremlin Server configured. Set GREMLIN_MCP_ENDPOINT to enable graph operations.'; + /** - * Creates and tests a Gremlin connection. - * - * This effect is responsible for establishing a connection to the Gremlin - * server, creating a client and a graph traversal source (`g`), and then - * testing the connection to ensure it is functional before it is used. - * - * @param config The application configuration containing Gremlin connection details. - * @returns An `Effect` that resolves to a `ConnectionState` object or fails with a `GremlinConnectionError`. + * Safely closes a Gremlin connection, logging any errors without propagating them. */ -const makeConnection = Effect.gen(function* () { - const config = yield* AppConfig; - const protocol = config.gremlin.useSSL ? 'wss' : 'ws'; - const url = `${protocol}://${config.gremlin.host}:${config.gremlin.port}/gremlin`; - const traversalSource = config.gremlin.traversalSource; - - yield* Effect.logInfo('Acquiring Gremlin connection', { - host: config.gremlin.host, - port: config.gremlin.port, - ssl: config.gremlin.useSSL, - traversalSource: config.gremlin.traversalSource, - }); - - const auth = Option.zipWith( - config.gremlin.username, - config.gremlin.password, - (username, password) => ({ username, password: Redacted.value(password) }) - ); - - // Build a proper Gremlin authenticator when credentials are provided - const authenticator = Option.map( - auth, - ({ username, password }) => new PlainTextSaslAuthenticator(username, password) - ); - - const connection = yield* Effect.try({ - try: () => - new DriverRemoteConnection(url, { - traversalSource, - authenticator: Option.getOrUndefined(authenticator), +const closeConnection = (state: ConnectionState): Effect.Effect<void, never> => + Effect.gen(function* () { + yield* Effect.logInfo('Closing Gremlin connection'); + yield* pipe( + Effect.tryPromise({ + try: () => state.connection.close(), + catch: error => Errors.connection('Failed to close Gremlin connection', { error }), }), - catch: error => Errors.connection('Failed to create remote connection', { error }), + Effect.catchAll(error => Effect.logWarning(`Error during connection close: ${error.message}`)) + ); + yield* Effect.logInfo('Gremlin connection closed'); }); - const g = AnonymousTraversalSource.traversal().withRemote(connection); - const client = new Client(url, { - traversalSource, - authenticator: Option.getOrUndefined(authenticator), - }); +/** + * Creates a fresh Gremlin connection. Only called when the endpoint IS configured. + */ +const createConnection = ( + host: string, + port: number, + traversalSource: string, + useSSL: boolean, + authenticatorInput: Option.Option<{ username: string; password: string }> +): Effect.Effect<ConnectionState, GremlinConnectionError> => + Effect.gen(function* () { + const protocol = useSSL ? 'wss' : 'ws'; + const url = `${protocol}://${host}:${port}/gremlin`; - // Test the connection - yield* Effect.tryPromise({ - try: () => g.inject(1).next(), - catch: error => Errors.connection('Connection test failed', { error }), - }); + yield* Effect.logInfo('Creating Gremlin connection', { + host, + port, + ssl: useSSL, + traversalSource, + }); - yield* Effect.logInfo('✅ Gremlin connection acquired successfully'); + const authenticator = Option.map( + authenticatorInput, + ({ username, password }) => new PlainTextSaslAuthenticator(username, password) + ); - return { - client, - connection, - g, - lastUsed: Date.now(), - }; -}); + const connection = yield* Effect.try({ + try: () => + new DriverRemoteConnection(url, { + traversalSource, + authenticator: Option.getOrUndefined(authenticator), + }), + catch: error => Errors.connection('Failed to create remote connection', { error }), + }); -/** - * Safely closes a Gremlin connection. - * - * This effect takes a `ConnectionState` and closes the underlying connection, - * logging any errors that occur during the process. - * - * @param state The `ConnectionState` to be closed. - * @returns An `Effect` that completes when the connection is closed. - */ -const releaseConnection = (state: ConnectionState) => - Effect.gen(function* () { - yield* Effect.logInfo('Releasing Gremlin connection'); + const g = AnonymousTraversalSource.traversal().withRemote(connection); + const client = new Client(url, { + traversalSource, + authenticator: Option.getOrUndefined(authenticator), + }); + + // Verify the server is reachable before caching yield* Effect.tryPromise({ - try: () => state.connection.close(), - catch: error => Errors.connection('Failed to close Gremlin connection', { error }), - }).pipe(Effect.catchAll(error => Effect.logWarning(`Error during release: ${error.message}`))); - yield* Effect.logInfo('Gremlin connection released successfully'); + try: () => g.inject(1).next(), + catch: error => Errors.connection('Connection test failed', { error }), + }); + + yield* Effect.logInfo('✅ Gremlin connection established and verified'); + + return { client, connection, g, lastUsed: Date.now() }; }); /** * A layer that provides a live `GremlinClient` service. * - * This layer is responsible for the lifecycle of the Gremlin connection. - * It acquires a connection when the layer is initialized and releases it - * when the application scope is closed. - * - * @example - * ```typescript - * import { Effect } from 'effect'; - * import { GremlinClientLive } from './connection.js'; - * - * const myApp = Effect.provide( - * // my effects... - * GremlinClientLive - * ); - * ``` + * Branches at construction time on whether GREMLIN_MCP_ENDPOINT is configured: + * - No endpoint → offline service; `getConnection` always fails, no driver objects created. + * - Endpoint set → online service; lazy cached connection with invalidate support. */ export const GremlinClientLive = Layer.scoped( GremlinClient, - Effect.acquireRelease(makeConnection, releaseConnection) + Effect.gen(function* () { + const config = yield* AppConfig; + + // ── Offline mode ────────────────────────────────────────────────────────── + if (Option.isNone(config.gremlin.endpoint)) { + yield* Effect.logInfo( + '⚠️ No GREMLIN_MCP_ENDPOINT configured — starting in offline mode. ' + + 'Translate and format tools are available; graph tools require an endpoint.' + ); + return GremlinClient.of({ + getConnection: Effect.fail(Errors.connection(NO_ENDPOINT_MSG)), + invalidate: Effect.void, + }); + } + + // ── Online mode ─────────────────────────────────────────────────────────── + const { host, port, traversalSource } = config.gremlin.endpoint.value; + + const authenticatorInput = Option.zipWith( + config.gremlin.username, + config.gremlin.password, + (username, password) => ({ username, password: Redacted.value(password) }) + ); + + const ref = yield* Ref.make<Option.Option<ConnectionState>>(Option.none()); + + // Release connection when the scope closes + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + const current = yield* Ref.get(ref); + if (Option.isSome(current)) { + yield* closeConnection(current.value); + } + }) + ); + + const getConnection: Effect.Effect<ConnectionState, GremlinConnectionError> = Effect.gen( + function* () { + const current = yield* Ref.get(ref); + if (Option.isSome(current)) { + return current.value; + } + const state = yield* createConnection( + host, + port, + traversalSource, + config.gremlin.useSSL, + authenticatorInput + ); + yield* Ref.set(ref, Option.some(state)); + return state; + } + ); + + const invalidate: Effect.Effect<void, never> = Effect.gen(function* () { + const current = yield* Ref.get(ref); + yield* Ref.set(ref, Option.none()); + if (Option.isSome(current)) { + yield* closeConnection(current.value); + } + }); + + return GremlinClient.of({ getConnection, invalidate }); + }) ); diff --git a/gremlin-mcp/src/main/javascript/src/gremlin/query-utils.ts b/gremlin-mcp/src/main/javascript/src/gremlin/query-utils.ts index 4ee5c9448f..aff8bd3fb4 100644 --- a/gremlin-mcp/src/main/javascript/src/gremlin/query-utils.ts +++ b/gremlin-mcp/src/main/javascript/src/gremlin/query-utils.ts @@ -33,6 +33,17 @@ type GraphTraversalSource = process.GraphTraversalSource; const { label } = gremlin.process.statics; +type CountResult = { value?: unknown }; + +const extractCountMap = (result: unknown): Map<string, number> | undefined => { + if (typeof result !== 'object' || result === null || !('value' in result)) { + return undefined; + } + + const value = (result as CountResult).value; + return value instanceof Map ? (value as Map<string, number>) : undefined; +}; + /** * Processes items in controlled batches with concurrency limiting. * @@ -127,7 +138,7 @@ export const getVertexCountsPerLabel = (g: GraphTraversalSource) => 'g.V().groupCount().by(label()).next()' ).pipe( Effect.map(result => { - const map = (result as any)?.value as Map<string, number> | undefined; + const map = extractCountMap(result); return { value: map ? (Object.fromEntries(map) as Record<string, number>) : undefined }; }) ); @@ -145,7 +156,7 @@ export const getEdgeCountsPerLabel = (g: GraphTraversalSource) => 'g.E().groupCount().by(label()).next()' ).pipe( Effect.map(result => { - const map = (result as any)?.value as Map<string, number> | undefined; + const map = extractCountMap(result); return { value: map ? (Object.fromEntries(map) as Record<string, number>) : undefined }; }) ); diff --git a/gremlin-mcp/src/main/javascript/src/gremlin/schema-generator.ts b/gremlin-mcp/src/main/javascript/src/gremlin/schema-generator.ts index aed0dc6656..98e74caf5a 100644 --- a/gremlin-mcp/src/main/javascript/src/gremlin/schema-generator.ts +++ b/gremlin-mcp/src/main/javascript/src/gremlin/schema-generator.ts @@ -27,7 +27,7 @@ import { Effect, Duration } from 'effect'; import { type GraphSchema, type Vertex, type Edge } from './models/index.js'; -import { Errors, type GremlinConnectionError, type GremlinQueryError } from '../errors.js'; +import { Errors, type GremlinQueryError } from '../errors.js'; import type { ConnectionState, SchemaConfig } from './types.js'; import { getVertexLabels, @@ -176,12 +176,8 @@ const applySchemaTimeout = ( export const generateGraphSchema = ( connectionState: ConnectionState, config: SchemaConfig = DEFAULT_SCHEMA_CONFIG -): Effect.Effect<GraphSchema, GremlinConnectionError | GremlinQueryError> => +): Effect.Effect<GraphSchema, GremlinQueryError> => Effect.gen(function* () { - if (!connectionState.g) { - return yield* Effect.fail(Errors.connection('Graph traversal source not available')); - } - const g = connectionState.g; const startTime = Date.now(); diff --git a/gremlin-mcp/src/main/javascript/src/gremlin/schema.ts b/gremlin-mcp/src/main/javascript/src/gremlin/schema.ts index 5ac1528a75..373ad8a1e5 100644 --- a/gremlin-mcp/src/main/javascript/src/gremlin/schema.ts +++ b/gremlin-mcp/src/main/javascript/src/gremlin/schema.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Effect, Context, Layer } from 'effect'; +import { Effect, Context, Layer, pipe } from 'effect'; import { generateGraphSchema, DEFAULT_SCHEMA_CONFIG } from './schema-generator.js'; import { createSchemaCache, @@ -50,11 +50,16 @@ export const SchemaServiceLive = Layer.effect( const config = yield* AppConfig; const cacheRef = yield* createSchemaCache(); - const generateSchemaEffect = generateGraphSchema(gremlinClient, { - ...DEFAULT_SCHEMA_CONFIG, - includeCounts: config.schema.includeCounts, - includeSampleValues: config.schema.includeSampleValues, - }); + const generateSchemaEffect = pipe( + gremlinClient.getConnection, + Effect.flatMap(conn => + generateGraphSchema(conn, { + ...DEFAULT_SCHEMA_CONFIG, + includeCounts: config.schema.includeCounts, + includeSampleValues: config.schema.includeSampleValues, + }) + ) + ); const getSchema = getCachedSchema(cacheRef, generateSchemaEffect); const peekSchema = peekCachedSchema(cacheRef); diff --git a/gremlin-mcp/src/main/javascript/src/gremlin/service.ts b/gremlin-mcp/src/main/javascript/src/gremlin/service.ts index 19e4cabc18..999c4900db 100644 --- a/gremlin-mcp/src/main/javascript/src/gremlin/service.ts +++ b/gremlin-mcp/src/main/javascript/src/gremlin/service.ts @@ -9,7 +9,7 @@ * * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, + * Unless required by applicable law or agreed in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the @@ -22,7 +22,8 @@ * * Provides a modular, composable service layer for Gremlin graph databases using * Effect-ts patterns. Handles connection management, schema caching, query execution, - * and error handling. + * and error handling. On any GremlinConnectionError the service invalidates the + * cached connection and retries the operation once to transparently reconnect. */ import { Effect, Context, Layer, pipe } from 'effect'; @@ -32,7 +33,7 @@ import { isGremlinResult } from '../utils/type-guards.js'; import { GremlinConnectionError, GremlinQueryError, Errors, ParseError } from '../errors.js'; import { GremlinClient } from './client.js'; import { SchemaService } from './schema.js'; -import type { GremlinClientType, GremlinResultSet } from './types.js'; +import type { GremlinClientType, GremlinResultSet, ConnectionState } from './types.js'; import type { GraphSchema, ServiceStatus } from './types.js'; /** @@ -63,24 +64,34 @@ export class GremlinService extends Context.Tag('GremlinService')< /** * Creates the Gremlin service implementation with dependency injection. * - * @returns Effect providing the complete service implementation - * - * The service implementation manages: - * - Connection state through the GremlinClient service - * - Schema cache with automatic generation and refresh capabilities - * - Query execution with result transformation and validation - * - Health monitoring and status reporting + * The service wraps all graph operations in a reconnect layer: on any + * GremlinConnectionError the cached connection is invalidated and the operation + * is retried once with a fresh connection. */ const makeGremlinService = Effect.gen(function* () { const gremlinClient = yield* GremlinClient; const schemaService = yield* SchemaService; + /** + * Wraps an Effect so that on GremlinConnectionError the connection is + * invalidated and the operation retried exactly once. + */ + const withReconnect = <A, E>( + operation: Effect.Effect<A, E | GremlinConnectionError> + ): Effect.Effect<A, E | GremlinConnectionError> => + pipe( + operation, + Effect.catchTag('GremlinConnectionError', () => + pipe( + Effect.logWarning('Connection error — invalidating and retrying once'), + Effect.andThen(gremlinClient.invalidate), + Effect.andThen(operation) + ) + ) + ); + /** * Executes a raw Gremlin query against the client. - * - * @param query - Gremlin traversal query string - * @param client - Gremlin client instance - * @returns Effect with query results or execution error */ const executeRawQuery = ( query: string, @@ -93,9 +104,6 @@ const makeGremlinService = Effect.gen(function* () { /** * Processes Gremlin ResultSet into standard array format. - * - * @param resultSet - Raw result from Gremlin client - * @returns Array of result items */ const processResultSet = (resultSet: unknown): unknown[] => { if (resultSet && typeof resultSet === 'object' && '_items' in resultSet) { @@ -109,10 +117,6 @@ const makeGremlinService = Effect.gen(function* () { /** * Transforms raw result set into parsed GremlinQueryResult format. - * - * @param query - Original query string for error context - * @param resultSet - Raw result set from Gremlin - * @returns Effect with parsed results and metadata */ const transformGremlinResult = ( query: string, @@ -130,10 +134,6 @@ const makeGremlinService = Effect.gen(function* () { /** * Validates query result against the GremlinQueryResult schema. - * - * @param query - Original query string for error context - * @param result - Parsed result object - * @returns Effect with validated GremlinQueryResult */ const validateQueryResult = ( query: string, @@ -145,17 +145,14 @@ const makeGremlinService = Effect.gen(function* () { ); /** - * Executes a Gremlin query with comprehensive error handling. - * - * @param query - Gremlin traversal query string - * @returns Effect with parsed and validated query results + * Core query execution — obtains the connection and runs the query. */ - const executeQuery = ( + const doExecuteQuery = ( query: string ): Effect.Effect<GremlinQueryResult, GremlinQueryError | GremlinConnectionError | ParseError> => pipe( - Effect.logDebug(`Executing Gremlin query: ${query}`), - Effect.andThen(() => executeRawQuery(query, gremlinClient.client)), + gremlinClient.getConnection, + Effect.flatMap(conn => executeRawQuery(query, conn.client)), Effect.filterOrFail(isGremlinResult, resultSet => Errors.query('Invalid result format received', query, resultSet) ), @@ -163,18 +160,59 @@ const makeGremlinService = Effect.gen(function* () { Effect.andThen(parsedResults => validateQueryResult(query, parsedResults)) ); - const getStatus = Effect.succeed({ status: 'connected' as const }); + /** + * Executes a Gremlin query with transparent reconnection on failure. + */ + const executeQuery = ( + query: string + ): Effect.Effect<GremlinQueryResult, GremlinQueryError | GremlinConnectionError | ParseError> => + pipe( + Effect.logDebug(`Executing Gremlin query: ${query}`), + Effect.andThen(() => withReconnect(doExecuteQuery(query))) + ); + + /** + * Performs a lightweight server round-trip to verify the current connection is still alive. + */ + const verifyConnectionAlive = ( + conn: ConnectionState + ): Effect.Effect<void, GremlinConnectionError> => + pipe( + Effect.tryPromise(() => conn.g.inject(1).next()), + Effect.asVoid, + Effect.mapError(error => Errors.connection('Connection health check failed', error)) + ); + + /** + * Returns current connection status without throwing. + */ + const getStatus: Effect.Effect<ServiceStatus, never> = pipe( + withReconnect( + pipe( + gremlinClient.getConnection, + Effect.flatMap(conn => verifyConnectionAlive(conn)), + Effect.as({ status: 'connected' as const }) + ) + ), + Effect.catchAll(() => Effect.succeed({ status: 'disconnected' as const })) + ); - const healthCheck = Effect.succeed({ - healthy: true, - details: 'Connected', - }); + const healthCheck: Effect.Effect<{ healthy: boolean; details: string }, never> = pipe( + withReconnect( + pipe( + gremlinClient.getConnection, + Effect.flatMap(conn => verifyConnectionAlive(conn)), + Effect.as({ healthy: true, details: 'Connected' }) + ) + ), + Effect.catchAll(() => Effect.succeed({ healthy: false, details: 'Connection unavailable' })) + ); return { getStatus, - getSchema: schemaService.getSchema, + getSchema: withReconnect(schemaService.getSchema), getCachedSchema: schemaService.peekSchema, - refreshSchemaCache: schemaService.refreshSchema, + refreshSchemaCache: withReconnect(schemaService.refreshSchema), executeQuery, healthCheck, } as const; @@ -183,7 +221,6 @@ const makeGremlinService = Effect.gen(function* () { /** * Creates a layer providing the Gremlin service implementation. * - * This layer depends on the `GremlinClient` service, which is expected - * to be provided elsewhere in the application's layer composition. + * Depends on `GremlinClient` and `SchemaService`. */ export const GremlinServiceLive = Layer.effect(GremlinService, makeGremlinService); diff --git a/gremlin-mcp/src/main/javascript/src/gremlin/types.ts b/gremlin-mcp/src/main/javascript/src/gremlin/types.ts index 04cf31720f..cb7c6b072d 100644 --- a/gremlin-mcp/src/main/javascript/src/gremlin/types.ts +++ b/gremlin-mcp/src/main/javascript/src/gremlin/types.ts @@ -32,7 +32,7 @@ export type GremlinConnection = driver.DriverRemoteConnection; export type GraphTraversalSource = process.GraphTraversalSource; /** - * Internal connection state - represents a fully initialized connection + * Active Gremlin connection state. Only created when the connection succeeds. */ export interface ConnectionState { readonly client: GremlinClientType; diff --git a/gremlin-mcp/src/main/javascript/src/handlers/resources.ts b/gremlin-mcp/src/main/javascript/src/handlers/resources.ts index 72fb372e88..092c485d5f 100644 --- a/gremlin-mcp/src/main/javascript/src/handlers/resources.ts +++ b/gremlin-mcp/src/main/javascript/src/handlers/resources.ts @@ -9,7 +9,7 @@ * * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, + * Unless required by applicable law or agreed in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the @@ -21,31 +21,47 @@ * @fileoverview MCP resource handlers for graph database information. * * Provides MCP resources that expose real-time graph database status and schema - * information. Resources are automatically updated and can be subscribed to by - * MCP clients for live monitoring. + * information. Resources are always registered; when no endpoint is configured + * they return a "disconnected - configure an endpoint" message. */ -import { Effect, pipe, Runtime } from 'effect'; +import { Effect, pipe, Runtime, Option } from 'effect'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { RESOURCE_URIS, MIME_TYPES } from '../constants.js'; -import { ERROR_PREFIXES } from '../errors.js'; import { GremlinService } from '../gremlin/service.js'; +import type { AppConfigType } from '../config.js'; + +const OFFLINE_STATUS = 'disconnected - configure an endpoint'; +const OFFLINE_SCHEMA = JSON.stringify( + { + status: 'disconnected', + message: 'Configure `GREMLIN_MCP_ENDPOINT` to enable graph operations.', + }, + null, + 2 +); /** * Registers MCP resource handlers with the server. * + * Both resources are always registered. When no endpoint is configured the + * responses contain a static "disconnected" message; otherwise the live + * Gremlin service is queried. + * * @param server - MCP server instance * @param runtime - Effect runtime with Gremlin service - * - * Registers resources for: - * - Graph connection status monitoring - * - Live schema information access + * @param config - Application configuration */ export function registerEffectResourceHandlers( server: McpServer, - runtime: Runtime.Runtime<GremlinService> + runtime: Runtime.Runtime<GremlinService>, + config: AppConfigType ): void { - // Register status resource using the recommended registerResource method + const endpointFromEnv = process.env['GREMLIN_MCP_ENDPOINT'] ?? ''; + const endpointIsConfiguredInEnv = endpointFromEnv.trim() !== ''; + const hasEndpoint = endpointIsConfiguredInEnv && Option.isSome(config.gremlin.endpoint); + + // Register status resource server.registerResource( 'status', RESOURCE_URIS.STATUS, @@ -54,13 +70,25 @@ export function registerEffectResourceHandlers( description: 'Real-time connection status of the Gremlin graph database', mimeType: MIME_TYPES.TEXT_PLAIN, }, - () => - Effect.runPromise( + (_uri, _extra) => { + if (!hasEndpoint) { + return Promise.resolve({ + contents: [ + { + uri: RESOURCE_URIS.STATUS, + mimeType: MIME_TYPES.TEXT_PLAIN, + text: OFFLINE_STATUS, + }, + ], + }); + } + + return Effect.runPromise( pipe( GremlinService, Effect.andThen(service => service.getStatus), Effect.map(statusObj => statusObj.status), - Effect.catchAll(error => Effect.succeed(`${ERROR_PREFIXES.CONNECTION}: ${error}`)), + Effect.catchAll(error => Effect.succeed(String(error))), Effect.provide(runtime) ) ).then(result => ({ @@ -71,10 +99,11 @@ export function registerEffectResourceHandlers( text: result, }, ], - })) + })); + } ); - // Register schema resource using the recommended registerResource method + // Register schema resource server.registerResource( 'schema', RESOURCE_URIS.SCHEMA, @@ -84,12 +113,26 @@ export function registerEffectResourceHandlers( 'Complete schema of the graph including vertex labels, edge labels, and relationship patterns', mimeType: MIME_TYPES.APPLICATION_JSON, }, - () => - Effect.runPromise( + (_uri, _extra) => { + if (!hasEndpoint) { + return Promise.resolve({ + contents: [ + { + uri: RESOURCE_URIS.SCHEMA, + mimeType: MIME_TYPES.APPLICATION_JSON, + text: OFFLINE_SCHEMA, + }, + ], + }); + } + + return Effect.runPromise( pipe( GremlinService, Effect.andThen(service => service.getSchema), - Effect.catchAll(error => Effect.succeed({ error: String(error) })), + Effect.catchAll(error => + Effect.succeed({ status: 'disconnected', message: String(error) }) + ), Effect.provide(runtime) ) ).then(result => ({ @@ -100,6 +143,7 @@ export function registerEffectResourceHandlers( text: JSON.stringify(result, null, 2), }, ], - })) + })); + } ); } diff --git a/gremlin-mcp/src/main/javascript/src/handlers/tools.ts b/gremlin-mcp/src/main/javascript/src/handlers/tools.ts index bb143804fc..f9173b6a12 100644 --- a/gremlin-mcp/src/main/javascript/src/handlers/tools.ts +++ b/gremlin-mcp/src/main/javascript/src/handlers/tools.ts @@ -25,7 +25,7 @@ * for service access. */ -import { Effect, Runtime, pipe } from 'effect'; +import { Effect, Runtime, Option, pipe } from 'effect'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import { TOOL_NAMES } from '../constants.js'; @@ -33,10 +33,17 @@ import { GremlinService } from '../gremlin/service.js'; import { createToolEffect, createStringToolEffect, - createQueryEffect, createSuccessResponse, } from './tool-patterns.js'; import { formatQuery } from 'gremlint'; +import { GremlinTranslator } from 'gremlin-language/language'; +import { normalizeAndTranslate } from '../translator/index.js'; +import type { AppConfigType } from '../config.js'; +import { + markGremlinConnected, + markGremlinDisconnected, + isGremlinConnectionError, +} from '../connectivity-state.js'; /** * Input validation schemas for tool parameters. @@ -68,21 +75,119 @@ const formatQueryInputSchema = z }) .strict(); +// Translate Gremlin Query input +const TRANSLATE_TARGETS = [ + 'canonical', + 'javascript', + 'python', + 'go', + 'dotnet', + 'java', + 'groovy', + 'anonymized', +] as const; +type TranslateTarget = (typeof TRANSLATE_TARGETS)[number]; + +const toUnknownRecord = (value: unknown): Record<string, unknown> | null => + typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null; + +const TARGET_TO_TRANSLATOR_KEY: Record<TranslateTarget, string> = { + canonical: 'CANONICAL', + javascript: 'JAVASCRIPT', + python: 'PYTHON', + go: 'GO', + dotnet: 'DOTNET', + java: 'JAVA', + groovy: 'GROOVY', + anonymized: 'ANONYMIZED', +}; + +const TRANSLATE_SOURCES = [ + 'canonical', + 'javascript', + 'python', + 'go', + 'dotnet', + 'java', + 'groovy', + 'auto', +] as const; + +const translateQueryInputSchema = z + .object({ + gremlin: z + .string() + .min(1, 'The Gremlin query cannot be empty') + .describe( + 'The Gremlin query string to translate. When source is omitted or "canonical", must use gremlin-language ANTLR grammar format. For any other source value, the query will be normalized automatically.' + ), + target: z.enum(TRANSLATE_TARGETS).describe('The target language to translate into'), + source: z + .enum(TRANSLATE_SOURCES) + .optional() + .describe( + 'The source dialect of the input query. Omit or use "auto" to normalize automatically via LLM before translating (default behavior). Use "canonical" to skip normalization and translate directly (only if your input is already in canonical gremlin-language ANTLR format).' + ), + traversalSource: z + .string() + .optional() + .describe('The traversal source variable name (default: "g")'), + }) + .strict(); + /** - * Registers all MCP tool handlers with the server. + * Registers MCP tool handlers with the server. + * + * Graph tools (status, schema, query) are only registered when a Gremlin endpoint is configured. Utility tools (translate, format) are always registered. * * @param server - MCP server instance * @param runtime - Effect runtime with Gremlin service - * - * Registers tools for: - * - Graph status monitoring - * - Schema introspection and caching - * - Query execution + * @param config - Application configuration */ export function registerEffectToolHandlers( server: McpServer, - runtime: Runtime.Runtime<GremlinService> + runtime: Runtime.Runtime<GremlinService>, + config: AppConfigType ): void { + if (Option.isSome(config.gremlin.endpoint)) { + registerGraphTools(server, runtime); + } + registerUtilityTools(server, runtime); +} + +/** + * Registers graph-connectivity tools (status, schema, query). + * Only called when a Gremlin endpoint is configured. + */ +function registerGraphTools(server: McpServer, runtime: Runtime.Runtime<GremlinService>): void { + const withOperationConnectivityTracking = <A, E, R>(effect: Effect.Effect<A, E, R>) => + pipe( + effect, + Effect.tap(() => Effect.sync(markGremlinConnected)), + Effect.tapError(error => + Effect.sync(() => { + if (isGremlinConnectionError(error)) { + markGremlinDisconnected(); + } + }) + ) + ); + + const getTrackedStatus = pipe( + GremlinService, + Effect.andThen(service => service.getStatus), + Effect.tap(statusObj => + Effect.sync(() => { + if (statusObj.status === 'connected') { + markGremlinConnected(); + } else { + markGremlinDisconnected(); + } + }) + ), + Effect.map(statusObj => statusObj.status) + ); + // Get Graph Status server.registerTool( TOOL_NAMES.GET_GRAPH_STATUS, @@ -94,12 +199,7 @@ export function registerEffectToolHandlers( () => Effect.runPromise( pipe( - createStringToolEffect( - Effect.andThen(GremlinService, service => - Effect.map(service.getStatus, statusObj => statusObj.status) - ), - 'Connection status check failed' - ), + createStringToolEffect(getTrackedStatus, 'Connection status check failed'), Effect.provide(runtime) ) ) @@ -118,7 +218,9 @@ export function registerEffectToolHandlers( Effect.runPromise( pipe( createToolEffect( - Effect.andThen(GremlinService, service => service.getSchema), + withOperationConnectivityTracking( + Effect.andThen(GremlinService, service => service.getSchema) + ), 'Schema retrieval failed' ), Effect.provide(runtime) @@ -138,8 +240,10 @@ export function registerEffectToolHandlers( Effect.runPromise( pipe( createStringToolEffect( - Effect.andThen(GremlinService, service => - Effect.map(service.refreshSchemaCache, () => 'Schema cache refreshed successfully.') + withOperationConnectivityTracking( + Effect.andThen(GremlinService, service => + Effect.map(service.refreshSchemaCache, () => 'Schema cache refreshed successfully.') + ) ), 'Failed to refresh schema' ), @@ -158,7 +262,101 @@ export function registerEffectToolHandlers( }, (args: unknown) => { const { query } = runQueryInputSchema.parse(args); - return Effect.runPromise(pipe(createQueryEffect(query), Effect.provide(runtime))); + return Effect.runPromise( + pipe( + GremlinService, + Effect.andThen(service => withOperationConnectivityTracking(service.executeQuery(query))), + Effect.map(createSuccessResponse), + Effect.catchAll(error => { + const errorResponse = { + results: [], + message: `Query failed: ${error}`, + }; + return Effect.succeed(createSuccessResponse(errorResponse)); + }), + Effect.provide(runtime) + ) + ); + } + ); +} + +/** + * Registers utility tools (translate, format) that do not require a Gremlin server. + * Always registered regardless of endpoint configuration. + */ +function registerUtilityTools(server: McpServer, runtime: Runtime.Runtime<GremlinService>): void { + // Translate Gremlin Query + server.registerTool( + TOOL_NAMES.TRANSLATE_GREMLIN_QUERY, + { + title: 'Translate Gremlin Query', + description: + 'Translate a Gremlin query into another language variant. By default, automatically normalizes the input via LLM before translating. Set source to "canonical" to skip normalization if the input is already in canonical gremlin-language ANTLR format.', + inputSchema: translateQueryInputSchema.shape, + }, + (args: unknown) => { + const { gremlin, target, source, traversalSource } = translateQueryInputSchema.parse(args); + const translatorKey = TARGET_TO_TRANSLATOR_KEY[target]; + const tsource = traversalSource ?? 'g'; + + if (source === 'canonical') { + // Approach 1: direct translation, canonical input assumed + try { + const result = GremlinTranslator.translate( + gremlin, + tsource, + translatorKey as Parameters<typeof GremlinTranslator.translate>[2] + ); + return Promise.resolve( + createSuccessResponse({ + success: true, + original: result.getOriginal(), + translated: result.getTranslated(), + target, + }) + ); + } catch (error) { + return Promise.resolve( + createSuccessResponse({ + success: false, + error: error instanceof Error ? error.message : String(error), + target, + }) + ); + } + } + + // Approach 2+5: mechanical normalization then LLM normalization via MCP sampling + return Effect.runPromise( + pipe( + Effect.tryPromise(() => + normalizeAndTranslate(gremlin, translatorKey, tsource, server.server) + ), + Effect.map(result => + createSuccessResponse({ + success: true, + original: result.original, + normalized: result.normalized, + translated: result.translated, + target, + ...(result.llmNormalizationSkipped && { + warning: + 'LLM normalization unavailable; result is based on mechanical normalization only and may be less accurate.', + }), + }) + ), + Effect.catchAll(error => + Effect.succeed( + createSuccessResponse({ + success: false, + error: error instanceof Error ? error.message : String(error), + target, + }) + ) + ) + ) + ); } ); @@ -197,9 +395,15 @@ export function registerEffectToolHandlers( error: { message: String(error), // include common error fields when present to make it structured - name: (error && (error as any).name) || undefined, - stack: (error && (error as any).stack) || undefined, - details: (error && (error as any).details) || undefined, + name: + toUnknownRecord(error) && typeof toUnknownRecord(error)?.['name'] === 'string' + ? (toUnknownRecord(error)?.['name'] as string) + : undefined, + stack: + toUnknownRecord(error) && typeof toUnknownRecord(error)?.['stack'] === 'string' + ? (toUnknownRecord(error)?.['stack'] as string) + : undefined, + details: toUnknownRecord(error)?.['details'], }, }) ) diff --git a/gremlin-mcp/src/main/javascript/src/server.ts b/gremlin-mcp/src/main/javascript/src/server.ts index 853f2030b5..cf68e0abea 100644 --- a/gremlin-mcp/src/main/javascript/src/server.ts +++ b/gremlin-mcp/src/main/javascript/src/server.ts @@ -29,13 +29,23 @@ * Built with Effect-ts for functional composition and error handling. */ -import { ConfigProvider, Effect, Layer, pipe, LogLevel, Logger, Context, Fiber } from 'effect'; +import { + ConfigProvider, + Effect, + Layer, + pipe, + LogLevel, + Logger, + Context, + Fiber, + Option, +} from 'effect'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { AppConfig, type AppConfigType } from './config.js'; import { GremlinService, GremlinServiceLive } from './gremlin/service.js'; -import { GremlinClientLive } from './gremlin/connection.js'; +import { GremlinClient } from './gremlin/client.js'; import { SchemaServiceLive } from './gremlin/schema.js'; import { registerEffectToolHandlers } from './handlers/tools.js'; import { registerEffectResourceHandlers } from './handlers/resources.js'; @@ -85,8 +95,8 @@ const makeMcpServerService = Effect.gen(function* () { const runtime = yield* Effect.runtime<GremlinService>(); // Register handlers with dependency injection - registerEffectToolHandlers(server, runtime); - registerEffectResourceHandlers(server, runtime); + registerEffectToolHandlers(server, runtime, config); + registerEffectResourceHandlers(server, runtime, config); yield* Effect.logInfo('✅ Handlers registered successfully', { service: config.server.name, @@ -130,13 +140,44 @@ const McpServerServiceLive = Layer.effect(McpServerService, makeMcpServerService /** * Layer composition providing all application dependencies. + * + * When GREMLIN_MCP_ENDPOINT is not set we use a gremlin-free offline stub so + * the gremlin driver is never imported or instantiated. When it IS set we + * dynamically import the real GremlinClientLive (which pulls in the driver). */ -const GremlinLayer = Layer.provide(GremlinServiceLive, SchemaServiceLive); -const AppLayer = Layer.provide( - McpServerServiceLive, - Layer.provide(GremlinLayer, GremlinClientLive) +const NO_ENDPOINT_MSG = + 'No Gremlin Server configured. Set GREMLIN_MCP_ENDPOINT to enable graph operations.'; + +const GremlinClientOfflineLive = Layer.succeed( + GremlinClient, + GremlinClient.of({ + getConnection: Effect.fail(Errors.connection(NO_ENDPOINT_MSG)), + invalidate: Effect.void, + }) ); +const GremlinLayer = Layer.provide(GremlinServiceLive, SchemaServiceLive); + +// Choose the client layer based purely on whether the env var is present at startup. +// This is checked before any Effect runs so the gremlin driver is never loaded when +// no endpoint is configured. +const endpointIsConfigured = + process.env['GREMLIN_MCP_ENDPOINT'] !== undefined && + process.env['GREMLIN_MCP_ENDPOINT'].trim() !== ''; + +// Dynamically import the real client layer only when an endpoint is provided, +// keeping the gremlin driver out of the module graph in offline mode. +const getGremlinClientLayer = async () => { + if (!endpointIsConfigured) { + return GremlinClientOfflineLive; + } + const { GremlinClientLive } = await import('./gremlin/connection.js'); + return GremlinClientLive; +}; + +// Resolved once at startup; used below in AppLayer. +const GremlinClientLayerPromise = getGremlinClientLayer(); + /** * Main application Effect. * @@ -147,10 +188,23 @@ const program = Effect.gen(function* () { // Get configuration const config = yield* AppConfig; + const endpointInfo = Option.match(config.gremlin.endpoint, { + onNone: () => ({ configured: false }), + onSome: ep => ({ + configured: true, + host: ep.host, + port: ep.port, + traversalSource: ep.traversalSource, + }), + }); + yield* Effect.logInfo('🚀 Starting Apache TinkerPop Gremlin MCP Server...', { service: config.server.name, version: config.server.version, - gremlinEndpoint: `${config.gremlin.host}:${config.gremlin.port}`, + gremlinEndpoint: Option.match(config.gremlin.endpoint, { + onNone: () => '(none)', + onSome: ep => `${ep.host}:${ep.port}`, + }), logLevel: config.logging.level, config: { server: { @@ -158,13 +212,11 @@ const program = Effect.gen(function* () { version: config.server.version, }, gremlin: { - host: config.gremlin.host, - port: config.gremlin.port, - traversalSource: config.gremlin.traversalSource, + ...endpointInfo, useSSL: config.gremlin.useSSL, idleTimeout: config.gremlin.idleTimeout, - username: config.gremlin.username ?? null, - hasPassword: Boolean(config.gremlin.password), + username: Option.getOrNull(config.gremlin.username), + hasPassword: Option.isSome(config.gremlin.password), }, schema: { enumDiscoveryEnabled: config.schema.enumDiscoveryEnabled, @@ -303,69 +355,97 @@ const withGracefulShutdown = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.E }); /** - * Main entry point with full Effect composition + * Main entry point with full Effect composition. + * + * @param appLayer - Fully composed application layer (built after resolving the + * gremlin client layer asynchronously so the gremlin driver is never imported + * when GREMLIN_MCP_ENDPOINT is not set). */ -const main = Effect.gen(function* () { - // Add startup logging before anything else - CRITICAL: Use stderr only - yield* logToStderr({ - level: 'info', - message: 'Apache TinkerPop - Gremlin MCP Server executable started', - process_info: { - pid: process.pid, - node_version: process.versions.node, - platform: process.platform, - argv: process.argv, - cwd: process.cwd(), - }, - }); - - // Get configuration early for logging setup - const config = yield* AppConfig; - - // Log configuration - yield* logToStderr({ - level: 'info', - message: 'Configuration loaded', - config: { - gremlin: { - host: config.gremlin.host, - port: config.gremlin.port, - use_ssl: config.gremlin.useSSL, - traversal_source: config.gremlin.traversalSource, - idle_timeout: config.gremlin.idleTimeout, +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const main = (appLayer: Layer.Layer<McpServerService, any, never>) => + Effect.gen(function* () { + // Add startup logging before anything else - CRITICAL: Use stderr only + yield* logToStderr({ + level: 'info', + message: 'Apache TinkerPop - Gremlin MCP Server executable started', + process_info: { + pid: process.pid, + node_version: process.versions.node, + platform: process.platform, + argv: process.argv, + cwd: process.cwd(), }, - logging: { - level: config.logging.level, + }); + + // Get configuration early for logging setup + const config = yield* AppConfig; + + // Log configuration + yield* logToStderr({ + level: 'info', + message: 'Configuration loaded', + config: { + gremlin: Option.match(config.gremlin.endpoint, { + onNone: () => ({ + configured: false, + use_ssl: config.gremlin.useSSL, + idle_timeout: config.gremlin.idleTimeout, + }), + onSome: ep => ({ + configured: true, + host: ep.host, + port: ep.port, + use_ssl: config.gremlin.useSSL, + traversal_source: ep.traversalSource, + idle_timeout: config.gremlin.idleTimeout, + }), + }), + logging: { + level: config.logging.level, + }, }, - }, + }); + + // Run the main program with all services provided + yield* pipe( + withGracefulShutdown(program), + Effect.provide(appLayer), + Effect.provide(createLoggerLayer(config)) + ); }); - // Run the main program with all services provided - yield* pipe( - withGracefulShutdown(program), - Effect.provide(AppLayer), - Effect.provide(createLoggerLayer(config)) - ); -}); - /** - * Run the application with improved error handling using Effect patterns + * Run the application with improved error handling using Effect patterns. + * + * Async so we can await the gremlin client layer resolution before building the + * full layer graph and starting the Effect runtime. */ // Ensure environment variables are used for configuration resolution across the app const EnvConfigLayer = Layer.setConfigProvider(ConfigProvider.fromEnv()); -const runMain = pipe( - Effect.scoped(main), - Effect.provide(EnvConfigLayer), - Effect.catchAll((error: unknown) => - logToStderr({ - level: 'error', - message: 'Fatal error in main program', - error: error instanceof Error ? error.message : String(error), - error_type: error instanceof Error ? error.constructor.name : typeof error, - stack: error instanceof Error ? error.stack : undefined, - }).pipe(Effect.andThen(() => Effect.sync(() => process.exit(1)))) - ) -); +async function run(): Promise<void> { + const gremlinClientLayer = await GremlinClientLayerPromise; + + const AppLayer = Layer.provide( + McpServerServiceLive, + Layer.provide(GremlinLayer, gremlinClientLayer) + ); + + const runMain = pipe( + Effect.scoped(main(AppLayer)), + Effect.provide(EnvConfigLayer), + Effect.catchAll((error: unknown) => + logToStderr({ + level: 'error', + message: 'Fatal error in main program', + error: error instanceof Error ? error.message : String(error), + error_type: error instanceof Error ? error.constructor.name : typeof error, + stack: error instanceof Error ? error.stack : undefined, + }).pipe(Effect.andThen(() => Effect.sync(() => process.exit(1)))) + ) + ); + + await Effect.runPromise(runMain); +} -Effect.runPromise(runMain); +run(); diff --git a/gremlin-mcp/src/main/javascript/src/translator/index.ts b/gremlin-mcp/src/main/javascript/src/translator/index.ts new file mode 100644 index 0000000000..05f187c442 --- /dev/null +++ b/gremlin-mcp/src/main/javascript/src/translator/index.ts @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * @fileoverview Orchestrates the normalize-and-translate pipeline. + * + * Pipeline: + * 1. Mechanical pre-processing (gremlingo. strip, trailing _, PascalCase→camelCase) + * 2. LLM normalization via MCP sampling (handles remaining dialect-specific constructs) + * 3. GremlinTranslator.translate() to the target language + */ + +import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { GremlinTranslator } from 'gremlin-language/language'; +import { applyMechanicalNormalization } from './normalizers/shared.js'; +import { normalizeWithLlm } from './llm.js'; + +export interface NormalizeAndTranslateResult { + readonly original: string; + readonly normalized: string; + readonly translated: string; + readonly llmNormalizationSkipped: boolean; +} + +/** + * Normalizes a Gremlin query from any dialect to canonical format, then translates + * it to the specified target language. + * + * @param query - Input Gremlin query in any dialect + * @param target - Translator key (e.g. 'JAVASCRIPT', 'PYTHON') + * @param traversalSource - Traversal source variable name (default: 'g') + * @param server - MCP server instance for LLM sampling + * @returns Original, normalized, and translated query strings + */ +export async function normalizeAndTranslate( + query: string, + target: string, + traversalSource: string, + server: Server +): Promise<NormalizeAndTranslateResult> { + const mechanical = applyMechanicalNormalization(query); + const [normalized, llmNormalizationSkipped] = await normalizeWithLlm(mechanical, server) + .then((result): [string, boolean] => [result, false]) + .catch((): [string, boolean] => [mechanical, true]); + const result = GremlinTranslator.translate( + normalized, + traversalSource, + target as Parameters<typeof GremlinTranslator.translate>[2] + ); + return { + original: query, + normalized, + translated: result.getTranslated(), + llmNormalizationSkipped, + }; +} diff --git a/gremlin-mcp/src/main/javascript/src/translator/llm.ts b/gremlin-mcp/src/main/javascript/src/translator/llm.ts new file mode 100644 index 0000000000..3339f98dc7 --- /dev/null +++ b/gremlin-mcp/src/main/javascript/src/translator/llm.ts @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * @fileoverview LLM-based normalization of Gremlin queries to canonical format. + * + * Uses MCP sampling to request normalization from the host LLM client, requiring + * no API key and no dependency on any specific LLM provider. + */ + +import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; + +const SYSTEM_PROMPT = `You are a Gremlin query normalizer. Convert the given Gremlin query to canonical gremlin-language ANTLR grammar format. + +Canonical format rules: +- Step names are camelCase: out(), hasLabel(), values() — not PascalCase +- Boolean literals are lowercase: true, false — not True, False +- Null literal is: null — not None +- No language-specific prefixes (gremlingo. has already been stripped) +- Enum values use canonical casing: T.label, T.id, T.key, T.value; Direction.OUT, Direction.IN, Direction.BOTH; Order.asc, Order.desc, Order.shuffle; Scope.local, Scope.global; Pop.first, Pop.last, Pop.all, Pop.mixed; Merge.onCreate, Merge.onMatch, Merge.outV, Merge.inV; Cardinality.single, Cardinality.list, Cardinality.set +- No type casts: use null instead of (Map) null, (Object) null, (String) null, etc. +- Collection literals: [a, b, c] for lists, {a, b, c} for sets, ["key": value] for maps +- Date literals: datetime("2023-01-01T00:00:00Z") — not OffsetDateTime.parse(...) +- UUID literals: uuid("...") or uuid() — not UUID.fromString(...) or UUID.randomUUID() +- No Java collection constructors (new ArrayList<>(), new HashSet<>(), new LinkedHashMap<>()) — use list/set/map literals + +Return ONLY the normalized Gremlin query. No explanation, no markdown, no surrounding text.`; + +/** + * Normalizes a Gremlin query to canonical format using MCP sampling. + * Delegates to the host LLM client — no API key or vendor dependency required. + * + * @param query - Pre-processed Gremlin query (after mechanical normalization) + * @param server - MCP server instance used to issue the sampling request + * @returns Normalized canonical Gremlin query string + * @throws If the client does not support sampling or the response is not text + */ +export async function normalizeWithLlm(query: string, server: Server): Promise<string> { + const result = await server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: query } }], + systemPrompt: SYSTEM_PROMPT, + maxTokens: 1024, + }); + + if (result.content.type !== 'text') { + throw new Error('Unexpected response type from LLM normalization'); + } + + return result.content.text.trim(); +} diff --git a/gremlin-mcp/src/main/javascript/src/translator/normalizers/shared.ts b/gremlin-mcp/src/main/javascript/src/translator/normalizers/shared.ts new file mode 100644 index 0000000000..82f2ebf692 --- /dev/null +++ b/gremlin-mcp/src/main/javascript/src/translator/normalizers/shared.ts @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * @fileoverview Mechanical pre-normalization transforms applied before LLM normalization. + * + * These transforms are cheap, deterministic, and language-agnostic. They reduce noise + * before the LLM sees the query: + * 1. Strip gremlingo. prefix (Go dialect) + * 2. Strip trailing _ from known step names (reserved word workarounds) + * 3. Convert PascalCase step names to camelCase (Go/.NET dialects) + */ + +import { GREMLIN_STEP_NAMES, GREMLIN_STEP_NAMES_PASCAL } from '../stepNames.js'; + +/** + * Applies mechanical pre-normalization to a Gremlin query string. + * Safe to apply to any dialect including canonical — transforms are idempotent. + */ +export function applyMechanicalNormalization(query: string): string { + let result = query; + + // 1. Strip gremlingo. prefix + result = result.replace(/gremlingo\./g, ''); + + // 2. Strip trailing _ from known step names before ( + // e.g. in_( → in(, from_( → from( + // Only applies to names in GREMLIN_STEP_NAMES to avoid corrupting user identifiers. + result = result.replace(/\b(\w+)_\s*\(/g, (_match, name: string) => { + if (GREMLIN_STEP_NAMES.has(name)) { + return `${name}(`; + } + return _match; + }); + + // 3. Convert PascalCase step names → camelCase + // e.g. HasLabel( → hasLabel(, Out( → out( + // Only converts tokens where (a) the PascalCase form is in GREMLIN_STEP_NAMES_PASCAL and + // (b) the resulting camelCase form is in GREMLIN_STEP_NAMES. The second check handles + // steps like V and E whose canonical form is already uppercase — converting them to + // v( or e( would be wrong. + result = result.replace(/\b([A-Z][a-zA-Z0-9]*)\s*\(/g, (_match, name: string) => { + if (GREMLIN_STEP_NAMES_PASCAL.has(name)) { + const camel = `${name.charAt(0).toLowerCase()}${name.slice(1)}`; + if (GREMLIN_STEP_NAMES.has(camel)) { + return `${camel}(`; + } + } + return _match; + }); + + return result; +} diff --git a/gremlin-mcp/src/main/javascript/src/translator/stepNames.ts b/gremlin-mcp/src/main/javascript/src/translator/stepNames.ts new file mode 100644 index 0000000000..c6269f6769 --- /dev/null +++ b/gremlin-mcp/src/main/javascript/src/translator/stepNames.ts @@ -0,0 +1,305 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Generated by scripts/generate-step-names.js — do not edit manually. +// Re-run `npm run generate-step-names` after changes to Gremlin.g4. + +/** + * Canonical camelCase Gremlin step names derived from the gremlin-language grammar. + * Used to safely identify step names during normalization. + */ +export const GREMLIN_STEP_NAMES: ReadonlySet<string> = new Set([ + 'E', + 'V', + 'addE', + 'addV', + 'aggregate', + 'all', + 'and', + 'any', + 'as', + 'asBool', + 'asDate', + 'asString', + 'barrier', + 'both', + 'bothE', + 'bothV', + 'branch', + 'by', + 'call', + 'cap', + 'choose', + 'coalesce', + 'coin', + 'combine', + 'concat', + 'conjoin', + 'connectedComponent', + 'constant', + 'count', + 'cyclicPath', + 'dateAdd', + 'dateDiff', + 'dedup', + 'difference', + 'discard', + 'disjunct', + 'drop', + 'element', + 'elementMap', + 'emit', + 'fail', + 'filter', + 'flatMap', + 'fold', + 'format', + 'from', + 'group', + 'groupCount', + 'has', + 'hasId', + 'hasKey', + 'hasLabel', + 'hasNot', + 'hasValue', + 'id', + 'identity', + 'in', + 'inE', + 'inV', + 'index', + 'inject', + 'intersect', + 'io', + 'is', + 'key', + 'lTrim', + 'label', + 'length', + 'limit', + 'local', + 'loops', + 'map', + 'match', + 'math', + 'max', + 'mean', + 'merge', + 'mergeE', + 'mergeV', + 'min', + 'none', + 'not', + 'option', + 'optional', + 'or', + 'order', + 'otherV', + 'out', + 'outE', + 'outV', + 'pageRank', + 'path', + 'peerPressure', + 'product', + 'profile', + 'project', + 'properties', + 'property', + 'propertyMap', + 'rTrim', + 'range', + 'read', + 'repeat', + 'replace', + 'reverse', + 'sack', + 'sample', + 'select', + 'shortestPath', + 'sideEffect', + 'simplePath', + 'skip', + 'split', + 'subgraph', + 'substring', + 'sum', + 'tail', + 'timeLimit', + 'times', + 'to', + 'toE', + 'toLower', + 'toUpper', + 'toV', + 'tree', + 'trim', + 'unfold', + 'union', + 'until', + 'value', + 'valueMap', + 'values', + 'where', + 'with', + 'write', +]); + +/** + * PascalCase equivalents of Gremlin step names, as used by Go and .NET dialects. + * Used as an allowlist when converting PascalCase tokens to camelCase during normalization. + */ +export const GREMLIN_STEP_NAMES_PASCAL: ReadonlySet<string> = new Set([ + 'E', + 'V', + 'AddE', + 'AddV', + 'Aggregate', + 'All', + 'And', + 'Any', + 'As', + 'AsBool', + 'AsDate', + 'AsString', + 'Barrier', + 'Both', + 'BothE', + 'BothV', + 'Branch', + 'By', + 'Call', + 'Cap', + 'Choose', + 'Coalesce', + 'Coin', + 'Combine', + 'Concat', + 'Conjoin', + 'ConnectedComponent', + 'Constant', + 'Count', + 'CyclicPath', + 'DateAdd', + 'DateDiff', + 'Dedup', + 'Difference', + 'Discard', + 'Disjunct', + 'Drop', + 'Element', + 'ElementMap', + 'Emit', + 'Fail', + 'Filter', + 'FlatMap', + 'Fold', + 'Format', + 'From', + 'Group', + 'GroupCount', + 'Has', + 'HasId', + 'HasKey', + 'HasLabel', + 'HasNot', + 'HasValue', + 'Id', + 'Identity', + 'In', + 'InE', + 'InV', + 'Index', + 'Inject', + 'Intersect', + 'Io', + 'Is', + 'Key', + 'LTrim', + 'Label', + 'Length', + 'Limit', + 'Local', + 'Loops', + 'Map', + 'Match', + 'Math', + 'Max', + 'Mean', + 'Merge', + 'MergeE', + 'MergeV', + 'Min', + 'None', + 'Not', + 'Option', + 'Optional', + 'Or', + 'Order', + 'OtherV', + 'Out', + 'OutE', + 'OutV', + 'PageRank', + 'Path', + 'PeerPressure', + 'Product', + 'Profile', + 'Project', + 'Properties', + 'Property', + 'PropertyMap', + 'RTrim', + 'Range', + 'Read', + 'Repeat', + 'Replace', + 'Reverse', + 'Sack', + 'Sample', + 'Select', + 'ShortestPath', + 'SideEffect', + 'SimplePath', + 'Skip', + 'Split', + 'Subgraph', + 'Substring', + 'Sum', + 'Tail', + 'TimeLimit', + 'Times', + 'To', + 'ToE', + 'ToLower', + 'ToUpper', + 'ToV', + 'Tree', + 'Trim', + 'Unfold', + 'Union', + 'Until', + 'Value', + 'ValueMap', + 'Values', + 'Where', + 'With', + 'Write', +]); diff --git a/gremlin-mcp/src/main/javascript/tests/config.test.ts b/gremlin-mcp/src/main/javascript/tests/config.test.ts index ed489009ab..b2467fad49 100644 --- a/gremlin-mcp/src/main/javascript/tests/config.test.ts +++ b/gremlin-mcp/src/main/javascript/tests/config.test.ts @@ -56,11 +56,12 @@ describe('Effect-based Configuration Management', () => { const result = await Effect.runPromise(AppConfig); + expect(Option.isSome(result.gremlin.endpoint)).toBe(true); + expect(result.gremlin.endpoint).toEqual( + Option.some({ host: 'localhost', port: 8182, traversalSource: 'g' }) + ); expect(result).toMatchObject({ gremlin: { - host: 'localhost', - port: 8182, - traversalSource: 'g', useSSL: false, idleTimeout: 300, }, @@ -89,11 +90,11 @@ describe('Effect-based Configuration Management', () => { const result = await Effect.runPromise(AppConfig); + expect(result.gremlin.endpoint).toEqual( + Option.some({ host: 'localhost', port: 8182, traversalSource: 'g' }) + ); expect(result).toMatchObject({ gremlin: { - host: 'localhost', - port: 8182, - traversalSource: 'g', useSSL: false, idleTimeout: 300, }, @@ -110,10 +111,12 @@ describe('Effect-based Configuration Management', () => { }); }); - it('should fail when required GREMLIN_MCP_ENDPOINT is missing', async () => { + it('should succeed with no GREMLIN_MCP_ENDPOINT (offline mode)', async () => { delete process.env.GREMLIN_MCP_ENDPOINT; - await expect(Effect.runPromise(AppConfig)).rejects.toThrow(); + const result = await Effect.runPromise(AppConfig); + + expect(result.gremlin.endpoint).toEqual(Option.none()); }); it('should parse boolean values correctly', async () => { @@ -136,9 +139,9 @@ describe('Effect-based Configuration Management', () => { const result = await Effect.runPromise(AppConfig); - expect(result.gremlin.host).toBe('localhost'); - expect(result.gremlin.port).toBe(8182); - expect(result.gremlin.traversalSource).toBe('custom'); + expect(result.gremlin.endpoint).toEqual( + Option.some({ host: 'localhost', port: 8182, traversalSource: 'custom' }) + ); }); it('should parse comma-separated denylist', async () => { @@ -235,9 +238,7 @@ describe('Effect-based Configuration Management', () => { const result = await Effect.runPromise(AppConfig); // Check all required fields are present - expect(result.gremlin.host).toBeDefined(); - expect(result.gremlin.port).toBeDefined(); - expect(result.gremlin.traversalSource).toBeDefined(); + expect(Option.isSome(result.gremlin.endpoint)).toBe(true); expect(result.gremlin.useSSL).toBeDefined(); expect(result.gremlin.idleTimeout).toBeDefined(); expect(result.schema.enumDiscoveryEnabled).toBeDefined(); @@ -256,7 +257,9 @@ describe('Effect-based Configuration Management', () => { const result = await Effect.runPromise(AppConfig); - expect(result.gremlin.traversalSource).toBe('g'); + expect(result.gremlin.endpoint).toEqual( + Option.some({ host: 'localhost', port: 8182, traversalSource: 'g' }) + ); expect(result.gremlin.useSSL).toBe(false); expect(result.gremlin.idleTimeout).toBe(300); expect(result.schema.enumDiscoveryEnabled).toBe(true); diff --git a/gremlin-mcp/src/main/javascript/tests/resource-read.integration.test.ts b/gremlin-mcp/src/main/javascript/tests/resource-read.integration.test.ts new file mode 100644 index 0000000000..153d4377bf --- /dev/null +++ b/gremlin-mcp/src/main/javascript/tests/resource-read.integration.test.ts @@ -0,0 +1,145 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { Effect, Layer, Option } from 'effect'; +import { afterEach, beforeEach, describe, expect, it } from '@jest/globals'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerEffectResourceHandlers } from '../src/handlers/resources'; +import { RESOURCE_URIS } from '../src/constants'; +import type { AppConfigType } from '../src/config'; +import { GremlinService } from '../src/gremlin/service'; +import type { GraphSchema, GremlinQueryResult } from '../src/gremlin/models'; +const makeConfig = (): AppConfigType => + ({ + gremlin: { + endpoint: Option.some({ host: 'localhost', port: 8182, traversalSource: 'g' }), + useSSL: false, + username: Option.none(), + password: Option.none(), + idleTimeout: 300, + }, + schema: { + enumDiscoveryEnabled: true, + enumCardinalityThreshold: 10, + enumPropertyDenyList: [], + includeSampleValues: false, + maxEnumValues: 10, + includeCounts: false, + }, + server: { name: 'gremlin-mcp', version: 'test' }, + logging: { level: 'info', structured: true }, + }) as unknown as AppConfigType; +const makeFakeRuntime = (statusResult: 'connected' | 'disconnected' = 'connected') => { + const schema: GraphSchema = { + vertices: [], + edges: [], + edge_patterns: [], + metadata: { + generated_at: new Date().toISOString(), + generated_by: 'test', + sample_size_per_label: 0, + optimization_settings: { + include_counts: false, + include_sample_values: false, + max_enum_values: 10, + }, + }, + }; + + const queryResult: GremlinQueryResult = { + results: [], + message: 'ok', + }; + + const fakeService = GremlinService.of({ + getStatus: Effect.succeed({ status: statusResult }), + getSchema: Effect.succeed(schema), + getCachedSchema: Effect.succeed(null), + refreshSchemaCache: Effect.void, + executeQuery: (query: string) => { + void query; + return Effect.succeed(queryResult); + }, + healthCheck: Effect.succeed({ + healthy: statusResult === 'connected', + details: statusResult === 'connected' ? 'Connected' : 'Connection unavailable', + }), + }); + return Effect.runSync( + Effect.provide(Effect.runtime<GremlinService>(), Layer.succeed(GremlinService, fakeService)) + ); +}; +describe('resource read integration', () => { + const originalEndpoint = process.env.GREMLIN_MCP_ENDPOINT; + beforeEach(() => { + process.env.GREMLIN_MCP_ENDPOINT = 'localhost:8182/g'; + }); + afterEach(() => { + if (typeof originalEndpoint === 'string') { + process.env.GREMLIN_MCP_ENDPOINT = originalEndpoint; + } else { + delete process.env.GREMLIN_MCP_ENDPOINT; + } + }); + it('returns correct URI and content for status and schema resource reads', async () => { + const server = new McpServer({ name: 'resource-read-test-server', version: '1.0.0' }); + registerEffectResourceHandlers(server, makeFakeRuntime('connected'), makeConfig()); + const client = new Client({ name: 'resource-read-test-client', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + try { + const status = await client.readResource({ uri: RESOURCE_URIS.STATUS }); + const schema = await client.readResource({ uri: RESOURCE_URIS.SCHEMA }); + expect(status.contents[0]?.uri).toBe(RESOURCE_URIS.STATUS); + const statusText = + status.contents[0] && 'text' in status.contents[0] ? status.contents[0].text : ''; + expect(statusText).toBe('connected'); + expect(schema.contents[0]?.uri).toBe(RESOURCE_URIS.SCHEMA); + const schemaText = + schema.contents[0] && 'text' in schema.contents[0] ? schema.contents[0].text : ''; + expect(schemaText).toContain('vertices'); + expect(schemaText).toContain('edges'); + } finally { + await client.close(); + await server.close(); + } + }); + it('returns offline content when GREMLIN_MCP_ENDPOINT is not set', async () => { + delete process.env.GREMLIN_MCP_ENDPOINT; + const server = new McpServer({ name: 'resource-offline-test-server', version: '1.0.0' }); + registerEffectResourceHandlers(server, makeFakeRuntime('connected'), makeConfig()); + const client = new Client({ name: 'resource-offline-test-client', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + try { + const status = await client.readResource({ uri: RESOURCE_URIS.STATUS }); + const schema = await client.readResource({ uri: RESOURCE_URIS.SCHEMA }); + const statusText = + status.contents[0] && 'text' in status.contents[0] ? status.contents[0].text : ''; + expect(statusText).toBe('disconnected - configure an endpoint'); + const schemaText = + schema.contents[0] && 'text' in schema.contents[0] ? schema.contents[0].text : ''; + expect(schemaText).toContain('Configure `GREMLIN_MCP_ENDPOINT`'); + } finally { + await client.close(); + await server.close(); + } + }); +}); diff --git a/gremlin-mcp/src/main/javascript/tests/resources.test.ts b/gremlin-mcp/src/main/javascript/tests/resources.test.ts new file mode 100644 index 0000000000..a1200e37d4 --- /dev/null +++ b/gremlin-mcp/src/main/javascript/tests/resources.test.ts @@ -0,0 +1,133 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Option, Runtime } from 'effect'; +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerEffectResourceHandlers } from '../src/handlers/resources'; +import { RESOURCE_URIS, MIME_TYPES } from '../src/constants'; +import type { GremlinService } from '../src/gremlin/service'; +import type { AppConfigType } from '../src/config'; + +type ResourceHandler = ( + uri: URL, + extra: unknown +) => Promise<{ + contents: Array<{ uri: string; mimeType: string; text: string }>; +}>; + +const createMockServer = () => { + const handlers = new Map<string, ResourceHandler>(); + + const server = { + registerResource: ( + name: string, + _uri: string, + _meta: unknown, + handler: ResourceHandler + ): void => { + handlers.set(name, handler); + }, + } as unknown as McpServer; + + return { server, handlers }; +}; + +describe('resource handler offline behavior', () => { + const originalEndpoint = process.env.GREMLIN_MCP_ENDPOINT; + + beforeEach(() => { + delete process.env.GREMLIN_MCP_ENDPOINT; + }); + + afterEach(() => { + if (typeof originalEndpoint === 'string') { + process.env.GREMLIN_MCP_ENDPOINT = originalEndpoint; + } else { + delete process.env.GREMLIN_MCP_ENDPOINT; + } + }); + + it('returns offline status and schema when GREMLIN_MCP_ENDPOINT is missing', async () => { + const { server, handlers } = createMockServer(); + + const config = { + gremlin: { + // Simulate stale pre-parsed config; env var absence must still force offline resource output. + endpoint: Option.some({ host: 'localhost', port: 8182, traversalSource: 'g' }), + useSSL: false, + username: Option.none(), + password: Option.none(), + idleTimeout: 300, + }, + schema: { + enumDiscoveryEnabled: true, + enumCardinalityThreshold: 10, + enumPropertyDenyList: [], + includeSampleValues: false, + maxEnumValues: 10, + includeCounts: false, + }, + server: { + name: 'gremlin-mcp', + version: 'test', + }, + logging: { + level: 'info', + structured: true, + }, + } as unknown as AppConfigType; + + registerEffectResourceHandlers( + server, + Runtime.defaultRuntime as Runtime.Runtime<GremlinService>, + config + ); + + const status = await handlers.get('status')!(new URL(RESOURCE_URIS.STATUS), {}); + const schema = await handlers.get('schema')!(new URL(RESOURCE_URIS.SCHEMA), {}); + + expect(status).toEqual({ + contents: [ + { + uri: RESOURCE_URIS.STATUS, + mimeType: MIME_TYPES.TEXT_PLAIN, + text: 'disconnected - configure an endpoint', + }, + ], + }); + + expect(schema).toEqual({ + contents: [ + { + uri: RESOURCE_URIS.SCHEMA, + mimeType: MIME_TYPES.APPLICATION_JSON, + text: JSON.stringify( + { + status: 'disconnected', + message: 'Configure `GREMLIN_MCP_ENDPOINT` to enable graph operations.', + }, + null, + 2 + ), + }, + ], + }); + }); +}); diff --git a/gremlin-test/src/main/resources/org/apache/tinkerpop/gremlin/language/translator/translations.json b/gremlin-test/src/main/resources/org/apache/tinkerpop/gremlin/language/translator/translations.json index ff4d792054..8d15b389a3 100644 --- a/gremlin-test/src/main/resources/org/apache/tinkerpop/gremlin/language/translator/translations.json +++ b/gremlin-test/src/main/resources/org/apache/tinkerpop/gremlin/language/translator/translations.json @@ -25428,7 +25428,34 @@ ] }, { - "scenario": "g_mergeEXout_vadasX_optionXonCreate_created_YX_optionXonMatch_created_NX_exists_updated", + "scenario": "g_mergeEXlabel_knows_out_vadasX_optionXonCreate_created_YX_optionXonMatch_created_NX_exists_updated_override_prohibited", + "traversals": [ + { + "original": "g.addV(\"person\").property(\"name\", \"marko\").as(\"a\").addV(\"person\").property(\"name\", \"vadas\").as(\"b\").addE(\"knows\").from(\"a\").to(\"b\").property(\"created\",\"Y\").addE(\"knows\").from(\"b\").to(\"a\").property(\"created\",\"Y\")", + "language": "g.addV(\"person\").property(\"name\", \"marko\").as(\"a\").addV(\"person\").property(\"name\", \"vadas\").as(\"b\").addE(\"knows\").from(\"a\").to(\"b\").property(\"created\", \"Y\").addE(\"knows\").from(\"b\").to(\"a\").property(\"created\", \"Y\")", + "anonymized": "g.addV(string0).property(string1, string2).as(string3).addV(string0).property(string1, string4).as(string5).addE(string6).from(string3).to(string5).property(string7, string8).addE(string6).from(string5).to(string3).property(string7, string8)", + "dotnet": "g.AddV((string) \"person\").Property(\"name\", \"marko\").As(\"a\").AddV((string) \"person\").Property(\"name\", \"vadas\").As(\"b\").AddE((string) \"knows\").From(\"a\").To(\"b\").Property(\"created\", \"Y\").AddE((string) \"knows\").From(\"b\").To(\"a\").Property(\"created\", \"Y\")", + "go": "g.AddV(\"person\").Property(\"name\", \"marko\").As(\"a\").AddV(\"person\").Property(\"name\", \"vadas\").As(\"b\").AddE(\"knows\").From(\"a\").To(\"b\").Property(\"created\", \"Y\").AddE(\"knows\").From(\"b\").To(\"a\").Property(\"created\", \"Y\")", + "groovy": "g.addV(\"person\").property(\"name\", \"marko\").as(\"a\").addV(\"person\").property(\"name\", \"vadas\").as(\"b\").addE(\"knows\").from(\"a\").to(\"b\").property(\"created\", \"Y\").addE(\"knows\").from(\"b\").to(\"a\").property(\"created\", \"Y\")", + "java": "g.addV(\"person\").property(\"name\", \"marko\").as(\"a\").addV(\"person\").property(\"name\", \"vadas\").as(\"b\").addE(\"knows\").from(\"a\").to(\"b\").property(\"created\", \"Y\").addE(\"knows\").from(\"b\").to(\"a\").property(\"created\", \"Y\")", + "javascript": "g.addV(\"person\").property(\"name\", \"marko\").as(\"a\").addV(\"person\").property(\"name\", \"vadas\").as(\"b\").addE(\"knows\").from_(\"a\").to(\"b\").property(\"created\", \"Y\").addE(\"knows\").from_(\"b\").to(\"a\").property(\"created\", \"Y\")", + "python": "g.add_v('person').property('name', 'marko').as_('a').add_v('person').property('name', 'vadas').as_('b').add_e('knows').from_('a').to('b').property('created', 'Y').add_e('knows').from_('b').to('a').property('created', 'Y')" + }, + { + "original": "g.mergeE(xx1).option(Merge.onCreate,xx2).option(Merge.onMatch,xx3)", + "language": "g.mergeE(xx1).option(Merge.onCreate, xx2).option(Merge.onMatch, xx3)", + "anonymized": "g.mergeE(map0).option(Merge.onCreate, map1).option(Merge.onMatch, map2)", + "dotnet": "g.MergeE((IDictionary<object, object>) xx1).Option(Merge.OnCreate, (IDictionary<object, object>) xx2).Option(Merge.OnMatch, (IDictionary<object, object>) xx3)", + "go": "g.MergeE(xx1).Option(gremlingo.Merge.OnCreate, xx2).Option(gremlingo.Merge.OnMatch, xx3)", + "groovy": "g.mergeE(xx1).option(Merge.onCreate, xx2).option(Merge.onMatch, xx3)", + "java": "g.mergeE(xx1).option(Merge.onCreate, xx2).option(Merge.onMatch, xx3)", + "javascript": "g.mergeE(xx1).option(Merge.onCreate, xx2).option(Merge.onMatch, xx3)", + "python": "g.merge_e(xx1).option(Merge.on_create, xx2).option(Merge.on_match, xx3)" + } + ] + }, + { + "scenario": "g_mergeEXout_vadasX_optionXonCreate_created_YX_optionXonMatch_created_NX_exists_updated_error", "traversals": [ { "original": "g.addV(\"person\").property(\"name\", \"marko\").as(\"a\").addV(\"person\").property(\"name\", \"vadas\").as(\"b\").addE(\"knows\").from(\"a\").to(\"b\").property(\"created\",\"Y\").addE(\"knows\").from(\"b\").to(\"a\").property(\"created\",\"Y\")", @@ -25498,6 +25525,104 @@ } ] }, + { + "scenario": "g_mergeEXout_vadasX_optionXonCreate_created_YX_optionXonMatch_created_NX_exists_updated_override_prohibited", + "traversals": [ + { + "original": "g.addV(\"person\").property(\"name\", \"marko\").as(\"a\").addV(\"person\").property(\"name\", \"vadas\").as(\"b\").addE(\"knows\").from(\"a\").to(\"b\").property(\"created\",\"Y\").addE(\"knows\").from(\"b\").to(\"a\").property(\"created\",\"Y\")", + "language": "g.addV(\"person\").property(\"name\", \"marko\").as(\"a\").addV(\"person\").property(\"name\", \"vadas\").as(\"b\").addE(\"knows\").from(\"a\").to(\"b\").property(\"created\", \"Y\").addE(\"knows\").from(\"b\").to(\"a\").property(\"created\", \"Y\")", + "anonymized": "g.addV(string0).property(string1, string2).as(string3).addV(string0).property(string1, string4).as(string5).addE(string6).from(string3).to(string5).property(string7, string8).addE(string6).from(string5).to(string3).property(string7, string8)", + "dotnet": "g.AddV((string) \"person\").Property(\"name\", \"marko\").As(\"a\").AddV((string) \"person\").Property(\"name\", \"vadas\").As(\"b\").AddE((string) \"knows\").From(\"a\").To(\"b\").Property(\"created\", \"Y\").AddE((string) \"knows\").From(\"b\").To(\"a\").Property(\"created\", \"Y\")", + "go": "g.AddV(\"person\").Property(\"name\", \"marko\").As(\"a\").AddV(\"person\").Property(\"name\", \"vadas\").As(\"b\").AddE(\"knows\").From(\"a\").To(\"b\").Property(\"created\", \"Y\").AddE(\"knows\").From(\"b\").To(\"a\").Property(\"created\", \"Y\")", + "groovy": "g.addV(\"person\").property(\"name\", \"marko\").as(\"a\").addV(\"person\").property(\"name\", \"vadas\").as(\"b\").addE(\"knows\").from(\"a\").to(\"b\").property(\"created\", \"Y\").addE(\"knows\").from(\"b\").to(\"a\").property(\"created\", \"Y\")", + "java": "g.addV(\"person\").property(\"name\", \"marko\").as(\"a\").addV(\"person\").property(\"name\", \"vadas\").as(\"b\").addE(\"knows\").from(\"a\").to(\"b\").property(\"created\", \"Y\").addE(\"knows\").from(\"b\").to(\"a\").property(\"created\", \"Y\")", + "javascript": "g.addV(\"person\").property(\"name\", \"marko\").as(\"a\").addV(\"person\").property(\"name\", \"vadas\").as(\"b\").addE(\"knows\").from_(\"a\").to(\"b\").property(\"created\", \"Y\").addE(\"knows\").from_(\"b\").to(\"a\").property(\"created\", \"Y\")", + "python": "g.add_v('person').property('name', 'marko').as_('a').add_v('person').property('name', 'vadas').as_('b').add_e('knows').from_('a').to('b').property('created', 'Y').add_e('knows').from_('b').to('a').property('created', 'Y')" + }, + { + "original": "g.mergeE(xx1).option(Merge.onCreate,xx2).option(Merge.onMatch,xx3)", + "language": "g.mergeE(xx1).option(Merge.onCreate, xx2).option(Merge.onMatch, xx3)", + "anonymized": "g.mergeE(map0).option(Merge.onCreate, map1).option(Merge.onMatch, map2)", + "dotnet": "g.MergeE((IDictionary<object, object>) xx1).Option(Merge.OnCreate, (IDictionary<object, object>) xx2).Option(Merge.OnMatch, (IDictionary<object, object>) xx3)", + "go": "g.MergeE(xx1).Option(gremlingo.Merge.OnCreate, xx2).Option(gremlingo.Merge.OnMatch, xx3)", + "groovy": "g.mergeE(xx1).option(Merge.onCreate, xx2).option(Merge.onMatch, xx3)", + "java": "g.mergeE(xx1).option(Merge.onCreate, xx2).option(Merge.onMatch, xx3)", + "javascript": "g.mergeE(xx1).option(Merge.onCreate, xx2).option(Merge.onMatch, xx3)", + "python": "g.merge_e(xx1).option(Merge.on_create, xx2).option(Merge.on_match, xx3)" + } + ] + }, + { + "scenario": "g_withSideEffect_mergeEXout_vadasX_optionXonCreate_created_YX_optionXonMatch_created_NX_exists_updated_dynamic_override_sketchily_allowed", + "traversals": [ + { + "original": "g.addV(\"person\").property(\"name\", \"marko\").as(\"a\").addV(\"person\").property(\"name\", \"vadas\").as(\"b\").addE(\"knows\").from(\"a\").to(\"b\").property(\"created\",\"Y\").addE(\"knows\").from(\"b\").to(\"a\").property(\"created\",\"Y\")", + "language": "g.addV(\"person\").property(\"name\", \"marko\").as(\"a\").addV(\"person\").property(\"name\", \"vadas\").as(\"b\").addE(\"knows\").from(\"a\").to(\"b\").property(\"created\", \"Y\").addE(\"knows\").from(\"b\").to(\"a\").property(\"created\", \"Y\")", + "anonymized": "g.addV(string0).property(string1, string2).as(string3).addV(string0).property(string1, string4).as(string5).addE(string6).from(string3).to(string5).property(string7, string8).addE(string6).from(string5).to(string3).property(string7, string8)", + "dotnet": "g.AddV((string) \"person\").Property(\"name\", \"marko\").As(\"a\").AddV((string) \"person\").Property(\"name\", \"vadas\").As(\"b\").AddE((string) \"knows\").From(\"a\").To(\"b\").Property(\"created\", \"Y\").AddE((string) \"knows\").From(\"b\").To(\"a\").Property(\"created\", \"Y\")", + "go": "g.AddV(\"person\").Property(\"name\", \"marko\").As(\"a\").AddV(\"person\").Property(\"name\", \"vadas\").As(\"b\").AddE(\"knows\").From(\"a\").To(\"b\").Property(\"created\", \"Y\").AddE(\"knows\").From(\"b\").To(\"a\").Property(\"created\", \"Y\")", + "groovy": "g.addV(\"person\").property(\"name\", \"marko\").as(\"a\").addV(\"person\").property(\"name\", \"vadas\").as(\"b\").addE(\"knows\").from(\"a\").to(\"b\").property(\"created\", \"Y\").addE(\"knows\").from(\"b\").to(\"a\").property(\"created\", \"Y\")", + "java": "g.addV(\"person\").property(\"name\", \"marko\").as(\"a\").addV(\"person\").property(\"name\", \"vadas\").as(\"b\").addE(\"knows\").from(\"a\").to(\"b\").property(\"created\", \"Y\").addE(\"knows\").from(\"b\").to(\"a\").property(\"created\", \"Y\")", + "javascript": "g.addV(\"person\").property(\"name\", \"marko\").as(\"a\").addV(\"person\").property(\"name\", \"vadas\").as(\"b\").addE(\"knows\").from_(\"a\").to(\"b\").property(\"created\", \"Y\").addE(\"knows\").from_(\"b\").to(\"a\").property(\"created\", \"Y\")", + "python": "g.add_v('person').property('name', 'marko').as_('a').add_v('person').property('name', 'vadas').as_('b').add_e('knows').from_('a').to('b').property('created', 'Y').add_e('knows').from_('b').to('a').property('created', 'Y')" + }, + { + "original": "g.mergeE(xx1).option(Merge.onCreate, select(\"sideEffect1\")).option(Merge.onMatch,xx3)", + "language": "g.mergeE(xx1).option(Merge.onCreate, __.select(\"sideEffect1\")).option(Merge.onMatch, xx3)", + "anonymized": "g.mergeE(map0).option(Merge.onCreate, __.select(string0)).option(Merge.onMatch, map1)", + "dotnet": "g.MergeE((IDictionary<object, object>) xx1).Option(Merge.OnCreate, (ITraversal) __.Select<object>(\"sideEffect1\")).Option(Merge.OnMatch, (IDictionary<object, object>) xx3)", + "go": "g.MergeE(xx1).Option(gremlingo.Merge.OnCreate, gremlingo.T__.Select(\"sideEffect1\")).Option(gremlingo.Merge.OnMatch, xx3)", + "groovy": "g.mergeE(xx1).option(Merge.onCreate, __.select(\"sideEffect1\")).option(Merge.onMatch, xx3)", + "java": "g.mergeE(xx1).option(Merge.onCreate, __.select(\"sideEffect1\")).option(Merge.onMatch, xx3)", + "javascript": "g.mergeE(xx1).option(Merge.onCreate, __.select(\"sideEffect1\")).option(Merge.onMatch, xx3)", + "python": "g.merge_e(xx1).option(Merge.on_create, __.select('sideEffect1')).option(Merge.on_match, xx3)" + }, + { + "original": "g.V()", + "language": "g.V()", + "anonymized": "g.V()", + "dotnet": "g.V()", + "go": "g.V()", + "groovy": "g.V()", + "java": "g.V()", + "javascript": "g.V()", + "python": "g.V()" + }, + { + "original": "g.E()", + "language": "g.E()", + "anonymized": "g.E()", + "dotnet": "g.E()", + "go": "g.E()", + "groovy": "g.E()", + "java": "g.E()", + "javascript": "g.E()", + "python": "g.E()" + }, + { + "original": "g.E().hasLabel(\"knows\").has(\"created\",\"Y\")", + "language": "g.E().hasLabel(\"knows\").has(\"created\", \"Y\")", + "anonymized": "g.E().hasLabel(string0).has(string1, string2)", + "dotnet": "g.E().HasLabel(\"knows\").Has(\"created\", \"Y\")", + "go": "g.E().HasLabel(\"knows\").Has(\"created\", \"Y\")", + "groovy": "g.E().hasLabel(\"knows\").has(\"created\", \"Y\")", + "java": "g.E().hasLabel(\"knows\").has(\"created\", \"Y\")", + "javascript": "g.E().hasLabel(\"knows\").has(\"created\", \"Y\")", + "python": "g.E().has_label('knows').has('created', 'Y')" + }, + { + "original": "g.E().hasLabel(\"knows\").has(\"created\",\"N\").outV().has(\"name\",\"vadas\")", + "language": "g.E().hasLabel(\"knows\").has(\"created\", \"N\").outV().has(\"name\", \"vadas\")", + "anonymized": "g.E().hasLabel(string0).has(string1, string2).outV().has(string3, string4)", + "dotnet": "g.E().HasLabel(\"knows\").Has(\"created\", \"N\").OutV().Has(\"name\", \"vadas\")", + "go": "g.E().HasLabel(\"knows\").Has(\"created\", \"N\").OutV().Has(\"name\", \"vadas\")", + "groovy": "g.E().hasLabel(\"knows\").has(\"created\", \"N\").outV().has(\"name\", \"vadas\")", + "java": "g.E().hasLabel(\"knows\").has(\"created\", \"N\").outV().has(\"name\", \"vadas\")", + "javascript": "g.E().hasLabel(\"knows\").has(\"created\", \"N\").outV().has(\"name\", \"vadas\")", + "python": "g.E().has_label('knows').has('created', 'N').out_v().has('name', 'vadas')" + } + ] + }, { "scenario": "g_V_hasXperson_name_marko_X_mergeEXlabel_self_out_vadas1_in_vadas1X", "traversals": [ @@ -26305,6 +26430,33 @@ } ] }, + { + "scenario": "g_withSideEffect_withSideEffect_mergeE_outV_dynamic_override_prohibited", + "traversals": [ + { + "original": "g.addV(\"person\").property(\"name\", \"marko\").addV(\"person\").property(\"name\", \"vadas\")", + "language": "g.addV(\"person\").property(\"name\", \"marko\").addV(\"person\").property(\"name\", \"vadas\")", + "anonymized": "g.addV(string0).property(string1, string2).addV(string0).property(string1, string3)", + "dotnet": "g.AddV((string) \"person\").Property(\"name\", \"marko\").AddV((string) \"person\").Property(\"name\", \"vadas\")", + "go": "g.AddV(\"person\").Property(\"name\", \"marko\").AddV(\"person\").Property(\"name\", \"vadas\")", + "groovy": "g.addV(\"person\").property(\"name\", \"marko\").addV(\"person\").property(\"name\", \"vadas\")", + "java": "g.addV(\"person\").property(\"name\", \"marko\").addV(\"person\").property(\"name\", \"vadas\")", + "javascript": "g.addV(\"person\").property(\"name\", \"marko\").addV(\"person\").property(\"name\", \"vadas\")", + "python": "g.add_v('person').property('name', 'marko').add_v('person').property('name', 'vadas')" + }, + { + "original": "g.mergeE(xx1).option(onCreate, select(\"sideEffect1\"))", + "language": "g.mergeE(xx1).option(Merge.onCreate, __.select(\"sideEffect1\"))", + "anonymized": "g.mergeE(map0).option(Merge.onCreate, __.select(string0))", + "dotnet": "g.MergeE((IDictionary<object, object>) xx1).Option(Merge.OnCreate, (ITraversal) __.Select<object>(\"sideEffect1\"))", + "go": "g.MergeE(xx1).Option(gremlingo.Merge.OnCreate, gremlingo.T__.Select(\"sideEffect1\"))", + "groovy": "g.mergeE(xx1).option(Merge.onCreate, __.select(\"sideEffect1\"))", + "java": "g.mergeE(xx1).option(Merge.onCreate, __.select(\"sideEffect1\"))", + "javascript": "g.mergeE(xx1).option(Merge.onCreate, __.select(\"sideEffect1\"))", + "python": "g.merge_e(xx1).option(Merge.on_create, __.select('sideEffect1'))" + } + ] + }, { "scenario": "g_mergeE_inV_override_prohibited", "traversals": [ @@ -26332,6 +26484,33 @@ } ] }, + { + "scenario": "g_withSideEffect_mergeE_inV_dynamic_override_prohibited", + "traversals": [ + { + "original": "g.addV(\"person\").property(\"name\", \"marko\").addV(\"person\").property(\"name\", \"vadas\")", + "language": "g.addV(\"person\").property(\"name\", \"marko\").addV(\"person\").property(\"name\", \"vadas\")", + "anonymized": "g.addV(string0).property(string1, string2).addV(string0).property(string1, string3)", + "dotnet": "g.AddV((string) \"person\").Property(\"name\", \"marko\").AddV((string) \"person\").Property(\"name\", \"vadas\")", + "go": "g.AddV(\"person\").Property(\"name\", \"marko\").AddV(\"person\").Property(\"name\", \"vadas\")", + "groovy": "g.addV(\"person\").property(\"name\", \"marko\").addV(\"person\").property(\"name\", \"vadas\")", + "java": "g.addV(\"person\").property(\"name\", \"marko\").addV(\"person\").property(\"name\", \"vadas\")", + "javascript": "g.addV(\"person\").property(\"name\", \"marko\").addV(\"person\").property(\"name\", \"vadas\")", + "python": "g.add_v('person').property('name', 'marko').add_v('person').property('name', 'vadas')" + }, + { + "original": "g.mergeE(xx1).option(onCreate, select(\"sideEffect1\"))", + "language": "g.mergeE(xx1).option(Merge.onCreate, __.select(\"sideEffect1\"))", + "anonymized": "g.mergeE(map0).option(Merge.onCreate, __.select(string0))", + "dotnet": "g.MergeE((IDictionary<object, object>) xx1).Option(Merge.OnCreate, (ITraversal) __.Select<object>(\"sideEffect1\"))", + "go": "g.MergeE(xx1).Option(gremlingo.Merge.OnCreate, gremlingo.T__.Select(\"sideEffect1\"))", + "groovy": "g.mergeE(xx1).option(Merge.onCreate, __.select(\"sideEffect1\"))", + "java": "g.mergeE(xx1).option(Merge.onCreate, __.select(\"sideEffect1\"))", + "javascript": "g.mergeE(xx1).option(Merge.onCreate, __.select(\"sideEffect1\"))", + "python": "g.merge_e(xx1).option(Merge.on_create, __.select('sideEffect1'))" + } + ] + }, { "scenario": "g_mergeE_label_override_prohibited", "traversals": [ @@ -26359,6 +26538,33 @@ } ] }, + { + "scenario": "g_mergeE_label_dynamic_override_prohibited", + "traversals": [ + { + "original": "g.addV(\"person\").property(\"name\", \"marko\").addV(\"person\").property(\"name\", \"vadas\")", + "language": "g.addV(\"person\").property(\"name\", \"marko\").addV(\"person\").property(\"name\", \"vadas\")", + "anonymized": "g.addV(string0).property(string1, string2).addV(string0).property(string1, string3)", + "dotnet": "g.AddV((string) \"person\").Property(\"name\", \"marko\").AddV((string) \"person\").Property(\"name\", \"vadas\")", + "go": "g.AddV(\"person\").Property(\"name\", \"marko\").AddV(\"person\").Property(\"name\", \"vadas\")", + "groovy": "g.addV(\"person\").property(\"name\", \"marko\").addV(\"person\").property(\"name\", \"vadas\")", + "java": "g.addV(\"person\").property(\"name\", \"marko\").addV(\"person\").property(\"name\", \"vadas\")", + "javascript": "g.addV(\"person\").property(\"name\", \"marko\").addV(\"person\").property(\"name\", \"vadas\")", + "python": "g.add_v('person').property('name', 'marko').add_v('person').property('name', 'vadas')" + }, + { + "original": "g.mergeE(xx1).option(onCreate, select(\"sideEffect1\"))", + "language": "g.mergeE(xx1).option(Merge.onCreate, __.select(\"sideEffect1\"))", + "anonymized": "g.mergeE(map0).option(Merge.onCreate, __.select(string0))", + "dotnet": "g.MergeE((IDictionary<object, object>) xx1).Option(Merge.OnCreate, (ITraversal) __.Select<object>(\"sideEffect1\"))", + "go": "g.MergeE(xx1).Option(gremlingo.Merge.OnCreate, gremlingo.T__.Select(\"sideEffect1\"))", + "groovy": "g.mergeE(xx1).option(Merge.onCreate, __.select(\"sideEffect1\"))", + "java": "g.mergeE(xx1).option(Merge.onCreate, __.select(\"sideEffect1\"))", + "javascript": "g.mergeE(xx1).option(Merge.onCreate, __.select(\"sideEffect1\"))", + "python": "g.merge_e(xx1).option(Merge.on_create, __.select('sideEffect1'))" + } + ] + }, { "scenario": "g_mergeE_id_override_prohibited", "traversals": [ @@ -26386,6 +26592,33 @@ } ] }, + { + "scenario": "g_withSideEffect_mergeE_id_dynamic_override_prohibited", + "traversals": [ + { + "original": "g.addV(\"person\").property(\"name\", \"marko\").addV(\"person\").property(\"name\", \"vadas\")", + "language": "g.addV(\"person\").property(\"name\", \"marko\").addV(\"person\").property(\"name\", \"vadas\")", + "anonymized": "g.addV(string0).property(string1, string2).addV(string0).property(string1, string3)", + "dotnet": "g.AddV((string) \"person\").Property(\"name\", \"marko\").AddV((string) \"person\").Property(\"name\", \"vadas\")", + "go": "g.AddV(\"person\").Property(\"name\", \"marko\").AddV(\"person\").Property(\"name\", \"vadas\")", + "groovy": "g.addV(\"person\").property(\"name\", \"marko\").addV(\"person\").property(\"name\", \"vadas\")", + "java": "g.addV(\"person\").property(\"name\", \"marko\").addV(\"person\").property(\"name\", \"vadas\")", + "javascript": "g.addV(\"person\").property(\"name\", \"marko\").addV(\"person\").property(\"name\", \"vadas\")", + "python": "g.add_v('person').property('name', 'marko').add_v('person').property('name', 'vadas')" + }, + { + "original": "g.mergeE(xx1).option(onCreate, select(\"sideEffect1\"))", + "language": "g.mergeE(xx1).option(Merge.onCreate, __.select(\"sideEffect1\"))", + "anonymized": "g.mergeE(map0).option(Merge.onCreate, __.select(string0))", + "dotnet": "g.MergeE((IDictionary<object, object>) xx1).Option(Merge.OnCreate, (ITraversal) __.Select<object>(\"sideEffect1\"))", + "go": "g.MergeE(xx1).Option(gremlingo.Merge.OnCreate, gremlingo.T__.Select(\"sideEffect1\"))", + "groovy": "g.mergeE(xx1).option(Merge.onCreate, __.select(\"sideEffect1\"))", + "java": "g.mergeE(xx1).option(Merge.onCreate, __.select(\"sideEffect1\"))", + "javascript": "g.mergeE(xx1).option(Merge.onCreate, __.select(\"sideEffect1\"))", + "python": "g.merge_e(xx1).option(Merge.on_create, __.select('sideEffect1'))" + } + ] + }, { "scenario": "g_mergeV_mergeE_combination_new_vertices", "traversals": [ @@ -28578,6 +28811,22 @@ } ] }, + { + "scenario": "g_withSideEffect_mergeV_label_dynamic_override_prohibited", + "traversals": [ + { + "original": "g.mergeV(xx1).option(onCreate, select('sideEffect1'))", + "language": "g.mergeV(xx1).option(Merge.onCreate, __.select('sideEffect1'))", + "anonymized": "g.mergeV(map0).option(Merge.onCreate, __.select(string0))", + "dotnet": "g.MergeV((IDictionary<object, object>) xx1).Option(Merge.OnCreate, (ITraversal) __.Select<object>(\"sideEffect1\"))", + "go": "g.MergeV(xx1).Option(gremlingo.Merge.OnCreate, gremlingo.T__.Select(\"sideEffect1\"))", + "groovy": "g.mergeV(xx1).option(Merge.onCreate, __.select('sideEffect1'))", + "java": "g.mergeV(xx1).option(Merge.onCreate, __.select(\"sideEffect1\"))", + "javascript": "g.mergeV(xx1).option(Merge.onCreate, __.select(\"sideEffect1\"))", + "python": "g.merge_v(xx1).option(Merge.on_create, __.select('sideEffect1'))" + } + ] + }, { "scenario": "g_mergeV_id_override_prohibited", "traversals": [
