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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.zestia.capsulemcp.model

trait CsvSerialisable:
def renderCsv: String

trait PartiallyCsvSerialisable:
def renderCsv: String

9 changes: 6 additions & 3 deletions src/main/scala/com/zestia/capsulemcp/model/FieldValue.scala
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,19 @@ final case class FieldValueString(
id: Long,
value: String,
definition: FieldDefinition
) extends FieldValue derives JsonDecoder, JsonEncoder
) extends FieldValue with CsvSerialisable derives JsonDecoder, JsonEncoder:
override def renderCsv: String = s"${definition.name}=${value}"

final case class FieldValueNumber(
id: Long,
value: Long,
definition: FieldDefinition
) extends FieldValue derives JsonDecoder, JsonEncoder
) extends FieldValue with CsvSerialisable derives JsonDecoder, JsonEncoder:
override def renderCsv: String = s"${definition.name}=${value}"

final case class FieldValueBoolean(
id: Long,
value: Boolean,
definition: FieldDefinition
) extends FieldValue derives JsonDecoder, JsonEncoder
) extends FieldValue with CsvSerialisable derives JsonDecoder, JsonEncoder:
override def renderCsv: String = s"${definition.name}=${value}"
37 changes: 28 additions & 9 deletions src/main/scala/com/zestia/capsulemcp/model/Party.scala
Original file line number Diff line number Diff line change
Expand Up @@ -12,31 +12,47 @@ final case class Address(
state: Option[String],
country: Option[String],
zip: Option[String]
) derives JsonDecoder,
JsonEncoder
) extends CsvSerialisable derives JsonDecoder, JsonEncoder:
override def renderCsv: String =
def nonEmpty(opt: Option[String]): Option[String] =
opt.map(_.trim).filter(_.nonEmpty)

val parts: List[(String, String)] =
List(
nonEmpty(`type`).map(t => "type" -> t),
nonEmpty(street).map(s => "street" -> s),
nonEmpty(city).map(c => "city" -> c),
nonEmpty(state).map(s => "state" -> s),
nonEmpty(country).map(c => "country" -> c),
nonEmpty(zip).map(z => "zip" -> z)
).flatten

parts.map { case (k, v) => s"$k=$v" }.mkString(";")

final case class EmailAddress(
id: Long,
`type`: Option[String],
address: String
) derives JsonDecoder,
JsonEncoder
) extends CsvSerialisable derives JsonDecoder, JsonEncoder:
override def renderCsv: String =
`type`.map(t => s"$t=$address").getOrElse(address)

final case class PhoneNumber(
id: Long,
`type`: Option[String],
number: String
) derives JsonDecoder,
JsonEncoder
) extends CsvSerialisable derives JsonDecoder, JsonEncoder:
override def renderCsv: String =
`type`.map(t => s"$t=$number").getOrElse(number)

final case class Website(
id: Long,
service: String,
address: String,
`type`: Option[String],
url: String
) derives JsonDecoder,
JsonEncoder
) extends CsvSerialisable derives JsonDecoder, JsonEncoder:
override def renderCsv: String = s"${service}=${address}"

enum PartyType:
case person, organisation
Expand Down Expand Up @@ -106,4 +122,7 @@ final case class Organisation(
owner: Option[User],
team: Option[Team],
missingImportantFields: Option[Boolean]
) extends Party derives JsonDecoder, JsonEncoder
) extends Party
with PartiallyCsvSerialisable derives JsonDecoder, JsonEncoder:
override def renderCsv: String =
s"id=$id${name.map(n => s";name=$n").getOrElse("")}"
2 changes: 2 additions & 0 deletions src/main/scala/com/zestia/capsulemcp/model/Responses.scala
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,5 @@ case class FieldDefinitionsResponse(definitions: List[FieldDefinition])
case class UsersResponse(users: List[User]) derives JsonDecoder, JsonEncoder

case class TeamsResponse(teams: List[Team]) derives JsonDecoder, JsonEncoder

case class ResponseWrapper(result: String, meta: Meta) derives JsonEncoder
5 changes: 3 additions & 2 deletions src/main/scala/com/zestia/capsulemcp/model/Tag.scala
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ case class Tag(
id: Long,
name: String,
description: Option[String]
) derives JsonDecoder,
JsonEncoder
) extends CsvSerialisable derives JsonDecoder,
JsonEncoder:
override def renderCsv: String = name
4 changes: 2 additions & 2 deletions src/main/scala/com/zestia/capsulemcp/model/Team.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ import zio.json.*
case class Team(
id: Long,
name: Option[String]
) derives JsonDecoder,
JsonEncoder
) extends CsvSerialisable derives JsonDecoder, JsonEncoder:
override def renderCsv: String = name.getOrElse("")
5 changes: 3 additions & 2 deletions src/main/scala/com/zestia/capsulemcp/model/User.scala
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ case class User(
id: Long,
username: String,
name: String
) derives JsonDecoder,
JsonEncoder
) extends CsvSerialisable derives JsonDecoder,
JsonEncoder:
override def renderCsv: String = name
14 changes: 11 additions & 3 deletions src/main/scala/com/zestia/capsulemcp/server/McpServer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import com.zestia.capsulemcp.model.filter.Filter
import com.zestia.capsulemcp.model.filter.*
import com.zestia.capsulemcp.model.filter.SimpleCondition.*
import com.zestia.capsulemcp.model.Pagination
import com.zestia.capsulemcp.util.{FileLogger, FileLogging}
import com.zestia.capsulemcp.util.{Csv, FileLogger, FileLogging, UnwrapList}
import com.tjclp.fastmcp.core.{Tool, ToolParam}
import com.tjclp.fastmcp.macros.RegistrationMacro.*
import com.tjclp.fastmcp.server.FastMcpServer
Expand Down Expand Up @@ -81,11 +81,19 @@ object CapsuleMcpServer extends FileLogging:
) pagination: Pagination,
@ToolParam("array of zero or more conditions") filter: Filter
): String = {
filterRequest[ContactsResponse](
val response = filterRequest[ContactsResponse](
"parties/filters/results",
filter,
pagination
).toJson
)

import UnwrapList.given
val parties: List[Party] =
summon[UnwrapList[ContactsResponse, Party]].apply(response)
val rows: List[Product] = parties.collect { case p: Product => p }
val csv = Csv.render(rows)

ResponseWrapper(csv, response.meta).toJson
}

@Tool(
Expand Down
82 changes: 82 additions & 0 deletions src/main/scala/com/zestia/capsulemcp/util/Csv.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package com.zestia.capsulemcp.util

import com.zestia.capsulemcp.model.*

object Csv extends FileLogging {

def render[T <: Product](
rows: List[T],
fieldsToInclude: List[String] = Nil
): String = {
val delimiter: Char = ','
if (rows.isEmpty) return ""

// Derive headers from the first row if not given
val allNames = rows.head.productElementNames.toList

logger.info(s"Rendering ${rows.size} rows with ${allNames.size} columns")
val headers =
if (fieldsToInclude.nonEmpty) fieldsToInclude
else allNames

logger.info(s"Rendering ${headers.size} columns: ${headers.mkString(",")}")

// Precompute index map
val nameToIndex: Map[String, Int] = allNames.zipWithIndex.toMap

// For each row, extract the requested columns by index
try {
val lines = rows.map { r =>
val nameToValue: Map[String, Any] =
r.productElementNames.zip(r.productIterator).toMap

headers
.map { h =>
val v = nameToValue.getOrElse(h, "")
escape(cellString(v))
}
.mkString(delimiter.toString)
}

(headers.map(escape).mkString(delimiter.toString) :: lines)
.mkString("\n")

} catch {
case t: Throwable =>
logger.error(s"Unexpected error writing to CSV: $t")

"Unexpected error writing to CSV"
}
}

private def escape(s: String): String = {
val needsQuotes =
s.exists(ch => ch == ',' || ch == '"' || ch == '\n' || ch == '\r')
val escapedQuotes =
if (!needsQuotes) s else "\"" + s.replace("\"", "\"\"") + "\""
escapedQuotes.replaceAll(
"\n",
" "
) // escape newlines allowed in places like addresses
}

private def cellString(value: Any): String = {
value match {
case null => ""
case None => ""
case Some(v) => cellString(v)
case xs: Iterable[_] => xs.map(cellString).mkString(";")
case csvSerialisable: CsvSerialisable => csvSerialisable.renderCsv
case csvSerialisable: PartiallyCsvSerialisable =>
csvSerialisable.renderCsv
case p: Product =>
p.productIterator
.map(cellString)
.mkString(";") // default to flatten any other Products
case s: String => s
case b: Boolean => b.toString
case n: java.lang.Number => n.toString
case other => other.toString
}
}
}
12 changes: 12 additions & 0 deletions src/main/scala/com/zestia/capsulemcp/util/UnwrapList.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.zestia.capsulemcp.util

import com.zestia.capsulemcp.model._

trait UnwrapList[W, A] { def apply(w: W): List[A] }

object UnwrapList {
given UnwrapList[ContactsResponse, Party] = (w: ContactsResponse) => w.parties
given UnwrapList[OpportunitiesResponse, Opportunity] =
(w: OpportunitiesResponse) => w.opportunities
given UnwrapList[ProjectsResponse, Project] = (w: ProjectsResponse) => w.kases
}